diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 187e0a3..77756da 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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: "list" }); return (
@@ -20,7 +28,19 @@ function App() { API key -
+
+ {view.view === "list" ? ( + setView({ view: "detail", dataset })} + /> + ) : ( + setView({ view: "list" })} + /> + )} +
); diff --git a/frontend/src/components/data-table.tsx b/frontend/src/components/data-table.tsx new file mode 100644 index 0000000..7c67404 --- /dev/null +++ b/frontend/src/components/data-table.tsx @@ -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) => 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 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 ( +

+ no data for this range +

+ ); + } + + const columns = Object.keys(firstEntry); + + return ( +
+ + + + timestamp + {columns.map((col) => ( + + {col} + + ))} + + + + {rows.flatMap((row) => + row.data.map((entry, i) => ( + + onRowClick({ timestamp: row.timestamp, ...entry }) + } + > + {i === 0 && ( + + {row.timestamp} + + )} + {columns.map((col) => ( + + {formatCell(entry[col])} + + ))} + + )), + )} + +
+
+ ); +} diff --git a/frontend/src/components/dataset-detail.tsx b/frontend/src/components/dataset-detail.tsx new file mode 100644 index 0000000..1d7c9f4 --- /dev/null +++ b/frontend/src/components/dataset-detail.tsx @@ -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 | 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 ( +
+ +

+ {name}{" "} + + {version} + +

+ + {isPending && ( +

loading data...

+ )} + + {isError && ( +
+

{error.message}

+ +
+ )} + + {data && ( + <> +

+ {data.returned_timestamps} timestamps · page {page + 1} of{" "} + {totalPages} +

+ + {totalPages > 1 && ( +
+ + + page {page + 1} / {totalPages} + + +
+ )} + + )} + + setSelectedRow(null)} /> +
+ ); +} diff --git a/frontend/src/components/dataset-list.tsx b/frontend/src/components/dataset-list.tsx new file mode 100644 index 0000000..7ad3d8f --- /dev/null +++ b/frontend/src/components/dataset-list.tsx @@ -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 ( +

loading datasets...

+ ); + } + + if (isError) { + return ( +
+

{error.message}

+ +
+ ); + } + + if (datasets.length === 0) { + return ( +

no datasets found

+ ); + } + + return ( +
+ + + + name + version + data + + + + {datasets.map((dataset) => ( + onSelect(dataset)} + > + {dataset.name} + + {dataset.version} + + + + + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/json-modal.tsx b/frontend/src/components/json-modal.tsx new file mode 100644 index 0000000..fc6cfd1 --- /dev/null +++ b/frontend/src/components/json-modal.tsx @@ -0,0 +1,32 @@ +import { JsonView } from "@/components/json-view"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface JsonModalProps { + data: Record | null; + onClose: () => void; +} + +/** modal showing one row's data as syntax-highlighted json */ +export function JsonModal({ data, onClose }: JsonModalProps) { + return ( + !open && onClose()}> + + + Row data + + JSON contents of the selected row + + +
+          {data !== null && }
+        
+
+
+ ); +} diff --git a/frontend/src/components/json-view.tsx b/frontend/src/components/json-view.tsx new file mode 100644 index 0000000..749d25c --- /dev/null +++ b/frontend/src/components/json-view.tsx @@ -0,0 +1,70 @@ +import { Fragment, type ReactNode } from "react"; + +const INDENT = " "; + +/** + * recursive syntax-highlighted json renderer. produces the same pretty-printed + * layout as JSON.stringify(value, null, 2) but as jsx spans, so no manual + * escaping or innerHTML is needed. + */ +export function JsonView({ value }: { value: unknown }) { + return <>{renderValue(value, 0)}; +} + +function renderValue(value: unknown, depth: number): ReactNode { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "boolean") { + return {String(value)}; + } + if (typeof value === "number") { + return {String(value)}; + } + if (typeof value === "string") { + return {JSON.stringify(value)}; + } + if (Array.isArray(value)) { + if (value.length === 0) { + return "[]"; + } + const inner = INDENT.repeat(depth + 1); + return ( + <> + {"[\n"} + {value.map((item, i) => ( + + {inner} + {renderValue(item, depth + 1)} + {i < value.length - 1 ? "," : ""} + {"\n"} + + ))} + {INDENT.repeat(depth)} + {"]"} + + ); + } + const entries = Object.entries(value as Record); + if (entries.length === 0) { + return "{}"; + } + const inner = INDENT.repeat(depth + 1); + return ( + <> + {"{\n"} + {entries.map(([key, val], i) => ( + + {inner} + {JSON.stringify(key)} + {": "} + {renderValue(val, depth + 1)} + {i < entries.length - 1 ? "," : ""} + {"\n"} + + ))} + {INDENT.repeat(depth)} + {"}"} + + ); +} diff --git a/frontend/src/hooks/use-dataset-data.ts b/frontend/src/hooks/use-dataset-data.ts new file mode 100644 index 0000000..02230da --- /dev/null +++ b/frontend/src/hooks/use-dataset-data.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchData } from "@/lib/api"; + +export function useDatasetData( + name: string, + version: string, + start: string, + end: string, +) { + return useQuery({ + queryKey: ["data", name, version, start, end], + queryFn: () => fetchData(name, version, start, end), + }); +} diff --git a/frontend/src/hooks/use-datasets.ts b/frontend/src/hooks/use-datasets.ts new file mode 100644 index 0000000..33c4b0f --- /dev/null +++ b/frontend/src/hooks/use-datasets.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; + +import { fetchDatasets } from "@/lib/api"; + +export function useDatasets() { + return useQuery({ + queryKey: ["datasets"], + queryFn: fetchDatasets, + }); +}