Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { KeyRoundIcon } from "lucide-react";
import { useState } from "react";

import type { DatasetSummary } from "@/lib/api";

import { ApiKeyDialog } from "@/components/api-key-dialog";
import { DatasetDetail } from "@/components/dataset-detail";
import { DatasetList } from "@/components/dataset-list";
import { Button } from "@/components/ui/button";
import { useApiKey } from "@/hooks/use-api-key";

type View = { view: "list" } | { view: "detail"; dataset: DatasetSummary };

function App() {
const { setDialogOpen } = useApiKey();
const [view, setView] = useState<View>({ view: "list" });

return (
<div className="mx-auto max-w-[1200px] px-8 py-6">
Expand All @@ -20,7 +28,19 @@ function App() {
<KeyRoundIcon /> API key
</Button>
</header>
<main />
<main>
{view.view === "list" ? (
<DatasetList
onSelect={(dataset) => setView({ view: "detail", dataset })}
/>
) : (
<DatasetDetail
name={view.dataset.name}
version={view.dataset.version}
onBack={() => setView({ view: "list" })}
/>
)}
</main>
<ApiKeyDialog />
</div>
);
Expand Down
88 changes: 88 additions & 0 deletions frontend/src/components/data-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { DataRow } from "@/lib/api";

import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";

interface DataTableProps {
rows: DataRow[];
onRowClick: (payload: Record<string, unknown>) => void;
}

function formatCell(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}

/**
* renders dataset rows with columns derived from the first entry's keys.
* timestamps with multiple entries (e.g. one per ticker) render one <tr> per
* entry with the timestamp cell rowspanned across them.
*/
export function DataTable({ rows, onRowClick }: DataTableProps) {
const firstEntry = rows.find((row) => row.data.length > 0)?.data[0];

if (rows.length === 0 || firstEntry === undefined) {
return (
<p className="text-muted-foreground py-8 text-center text-sm">
no data for this range
</p>
);
}

const columns = Object.keys(firstEntry);

return (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-mono">timestamp</TableHead>
{columns.map((col) => (
<TableHead key={col} className="font-mono">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.flatMap((row) =>
row.data.map((entry, i) => (
<TableRow
key={`${row.timestamp}-${i}`}
className="cursor-pointer"
onClick={() =>
onRowClick({ timestamp: row.timestamp, ...entry })
}
>
{i === 0 && (
<TableCell
rowSpan={row.data.length}
className="text-muted-foreground align-top font-mono"
>
{row.timestamp}
</TableCell>
)}
{columns.map((col) => (
<TableCell key={col} className="font-mono">
{formatCell(entry[col])}
</TableCell>
))}
</TableRow>
)),
)}
</TableBody>
</Table>
</div>
);
}
106 changes: 106 additions & 0 deletions frontend/src/components/dataset-detail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { ArrowLeftIcon } from "lucide-react";
import { useMemo, useState } from "react";

import { DataTable } from "@/components/data-table";
import { JsonModal } from "@/components/json-modal";
import { Button } from "@/components/ui/button";
import { useDatasetData } from "@/hooks/use-dataset-data";
import { defaultDateRange } from "@/lib/format";

const PAGE_SIZE = 50;

interface DatasetDetailProps {
name: string;
version: string;
onBack: () => void;
}

/** detail view: paginated data table over a 5-year window, newest first */
export function DatasetDetail({ name, version, onBack }: DatasetDetailProps) {
// freeze the range for the lifetime of the view so the query key is stable
const [range] = useState(() => defaultDateRange());
const [page, setPage] = useState(0);
const [selectedRow, setSelectedRow] = useState<Record<
string,
unknown
> | null>(null);

const { data, isPending, isError, error, refetch } = useDatasetData(
name,
version,
range.start,
range.end,
);

// newest first
const allRows = useMemo(() => (data ? [...data.rows].reverse() : []), [data]);
const totalPages = Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));
const pageRows = allRows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);

return (
<div className="space-y-4">
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="text-muted-foreground -ml-2"
>
<ArrowLeftIcon /> back
</Button>
<h2 className="text-xl font-semibold">
{name}{" "}
<span className="text-muted-foreground font-mono text-base font-normal">
{version}
</span>
</h2>

{isPending && (
<p className="text-muted-foreground py-8 text-sm">loading data...</p>
)}

{isError && (
<div className="border-destructive/50 space-y-3 rounded-md border p-4">
<p className="text-destructive text-sm">{error.message}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
retry
</Button>
</div>
)}

{data && (
<>
<p className="text-muted-foreground text-sm">
{data.returned_timestamps} timestamps · page {page + 1} of{" "}
{totalPages}
</p>
<DataTable rows={pageRows} onRowClick={setSelectedRow} />
{totalPages > 1 && (
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
← newer
</Button>
<span className="text-muted-foreground text-sm">
page {page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
older →
</Button>
</div>
)}
</>
)}

<JsonModal data={selectedRow} onClose={() => setSelectedRow(null)} />
</div>
);
}
82 changes: 82 additions & 0 deletions frontend/src/components/dataset-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { DatasetSummary } from "@/lib/api";

import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useDatasets } from "@/hooks/use-datasets";
import { cn } from "@/lib/utils";

interface DatasetListProps {
onSelect: (dataset: DatasetSummary) => void;
}

/** landing page: all registered datasets with a dot marking which have data */
export function DatasetList({ onSelect }: DatasetListProps) {
const { data: datasets, isPending, isError, error, refetch } = useDatasets();

if (isPending) {
return (
<p className="text-muted-foreground py-8 text-sm">loading datasets...</p>
);
}

if (isError) {
return (
<div className="border-destructive/50 space-y-3 rounded-md border p-4">
<p className="text-destructive text-sm">{error.message}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
retry
</Button>
</div>
);
}

if (datasets.length === 0) {
return (
<p className="text-muted-foreground py-8 text-sm">no datasets found</p>
);
}

return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>name</TableHead>
<TableHead>version</TableHead>
<TableHead>data</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{datasets.map((dataset) => (
<TableRow
key={`${dataset.name}@${dataset.version}`}
className="cursor-pointer"
onClick={() => onSelect(dataset)}
>
<TableCell>{dataset.name}</TableCell>
<TableCell className="text-muted-foreground font-mono">
{dataset.version}
</TableCell>
<TableCell>
<span
className={cn(
"inline-block size-2 rounded-full",
dataset.has_data ? "bg-success" : "bg-border",
)}
title={dataset.has_data ? "has data" : "no data"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
32 changes: 32 additions & 0 deletions frontend/src/components/json-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { JsonView } from "@/components/json-view";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";

interface JsonModalProps {
data: Record<string, unknown> | null;
onClose: () => void;
}

/** modal showing one row's data as syntax-highlighted json */
export function JsonModal({ data, onClose }: JsonModalProps) {
return (
<Dialog open={data !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[80vh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>Row data</DialogTitle>
<DialogDescription className="sr-only">
JSON contents of the selected row
</DialogDescription>
</DialogHeader>
<pre className="overflow-auto rounded-md border p-4 font-mono text-sm">
<code>{data !== null && <JsonView value={data} />}</code>
</pre>
</DialogContent>
</Dialog>
);
}
Loading
Loading