diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 03310a3..7f19fb3 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -20,8 +20,9 @@ export default tseslint.config(
},
},
{
- // shadcn-generated components export variants/hooks next to components
- files: ["src/components/ui/**/*.tsx"],
+ // shadcn-generated components export variants/hooks next to components;
+ // context provider files export the provider component + its hook
+ files: ["src/components/ui/**/*.tsx", "src/hooks/**/*.tsx"],
rules: {
"react-refresh/only-export-components": "off",
},
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ae4c49a..187e0a3 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,10 +1,27 @@
+import { KeyRoundIcon } from "lucide-react";
+
+import { ApiKeyDialog } from "@/components/api-key-dialog";
+import { Button } from "@/components/ui/button";
+import { useApiKey } from "@/hooks/use-api-key";
+
function App() {
+ const { setDialogOpen } = useApiKey();
+
return (
-
+
Datastream
+
+
);
}
diff --git a/frontend/src/components/api-key-dialog.tsx b/frontend/src/components/api-key-dialog.tsx
new file mode 100644
index 0000000..b999d17
--- /dev/null
+++ b/frontend/src/components/api-key-dialog.tsx
@@ -0,0 +1,107 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useQueryClient } from "@tanstack/react-query";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useApiKey } from "@/hooks/use-api-key";
+import { maskApiKey } from "@/lib/api-key";
+
+const formSchema = z.object({
+ apiKey: z
+ .string()
+ .trim()
+ .regex(/^dsk_/, "keys start with dsk_")
+ .min(8, "key looks too short"),
+});
+
+type FormValues = z.infer;
+
+export function ApiKeyDialog() {
+ const { apiKey, saveApiKey, clearApiKey, dialogOpen, setDialogOpen } =
+ useApiKey();
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: { apiKey: "" },
+ });
+
+ function onSubmit(values: FormValues) {
+ saveApiKey(values.apiKey.trim());
+ form.reset();
+ setDialogOpen(false);
+ // refetch everything that failed without a key
+ void queryClient.invalidateQueries();
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/hooks/use-api-key.tsx b/frontend/src/hooks/use-api-key.tsx
new file mode 100644
index 0000000..da9d834
--- /dev/null
+++ b/frontend/src/hooks/use-api-key.tsx
@@ -0,0 +1,68 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+
+import { clearStoredApiKey, getApiKey, storeApiKey } from "@/lib/api-key";
+
+interface ApiKeyContextValue {
+ apiKey: string | null;
+ saveApiKey: (key: string) => void;
+ clearApiKey: () => void;
+ dialogOpen: boolean;
+ setDialogOpen: (open: boolean) => void;
+}
+
+const ApiKeyContext = createContext(null);
+
+// module-level hook so non-react code (the query client's error handler)
+// can pop the key dialog
+let openDialogListener: (() => void) | null = null;
+
+export function requestApiKey(): void {
+ openDialogListener?.();
+}
+
+export function ApiKeyProvider({ children }: { children: ReactNode }) {
+ const [apiKey, setApiKey] = useState(() => getApiKey());
+ // auto-open on first load when no key is stored: the backend rejects
+ // everything except /status without one
+ const [dialogOpen, setDialogOpen] = useState(() => !getApiKey());
+
+ useEffect(() => {
+ openDialogListener = () => setDialogOpen(true);
+ return () => {
+ openDialogListener = null;
+ };
+ }, []);
+
+ const saveApiKey = useCallback((key: string) => {
+ storeApiKey(key);
+ setApiKey(key);
+ }, []);
+
+ const clearApiKey = useCallback(() => {
+ clearStoredApiKey();
+ setApiKey(null);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useApiKey(): ApiKeyContextValue {
+ const ctx = useContext(ApiKeyContext);
+ if (!ctx) {
+ throw new Error("useApiKey must be used inside ApiKeyProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/lib/api-key.ts b/frontend/src/lib/api-key.ts
new file mode 100644
index 0000000..15f2afb
--- /dev/null
+++ b/frontend/src/lib/api-key.ts
@@ -0,0 +1,18 @@
+const STORAGE_KEY = "datastream.api_key";
+
+export function getApiKey(): string | null {
+ return localStorage.getItem(STORAGE_KEY);
+}
+
+export function storeApiKey(key: string): void {
+ localStorage.setItem(STORAGE_KEY, key);
+}
+
+export function clearStoredApiKey(): void {
+ localStorage.removeItem(STORAGE_KEY);
+}
+
+/** masked display form of a stored key, e.g. "dsk_****cd12" */
+export function maskApiKey(key: string): string {
+ return `${key.slice(0, 4)}****${key.slice(-4)}`;
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..ac78629
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,68 @@
+import { getApiKey } from "@/lib/api-key";
+
+// dev: unset → relative /api/v1 → vite proxy. prod: absolute url baked at build time
+const BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/v1`;
+
+export class ApiError extends Error {
+ constructor(
+ public readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+export interface DatasetSummary {
+ name: string;
+ version: string;
+ has_data: boolean;
+}
+
+export interface DataRow {
+ timestamp: string;
+ data: Record[];
+}
+
+export interface DataResponse {
+ dataset_name: string;
+ dataset_version: string;
+ total_timestamps: number;
+ returned_timestamps: number;
+ rows: DataRow[];
+}
+
+async function apiFetch(
+ path: string,
+ params?: Record,
+ okStatuses: number[] = [200],
+): Promise {
+ const query = params ? `?${new URLSearchParams(params)}` : "";
+ const key = getApiKey();
+ const res = await fetch(`${BASE}${path}${query}`, {
+ headers: key ? { Authorization: `Bearer ${key}` } : {},
+ });
+ if (!okStatuses.includes(res.status)) {
+ throw new ApiError(res.status, `request to ${path} failed: ${res.status}`);
+ }
+ return res.json() as Promise;
+}
+
+export async function fetchDatasets(): Promise {
+ const body = await apiFetch<{ datasets: DatasetSummary[] }>("/datasets");
+ return body.datasets;
+}
+
+// 206 = partial data, still a valid read; build-data=false so browsing never triggers builds
+export function fetchData(
+ name: string,
+ version: string,
+ start: string,
+ end: string,
+): Promise {
+ return apiFetch(
+ `/data/${encodeURIComponent(name)}/${encodeURIComponent(version)}`,
+ { start, end, "build-data": "false" },
+ [200, 206],
+ );
+}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
new file mode 100644
index 0000000..072c18b
--- /dev/null
+++ b/frontend/src/lib/format.ts
@@ -0,0 +1,22 @@
+export function formatTimestamp(isoString: string): string {
+ return new Date(isoString).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export function toISODate(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+/** default fetch window: today minus 5 years → today */
+export function defaultDateRange(): { start: string; end: string } {
+ const end = new Date();
+ const start = new Date(end);
+ start.setFullYear(start.getFullYear() - 5);
+ return { start: toISODate(start), end: toISODate(end) };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 604c29d..981014d 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,12 +1,51 @@
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+} from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { toast } from "sonner";
import App from "@/App";
+import { Toaster } from "@/components/ui/sonner";
+import { ApiKeyProvider, requestApiKey } from "@/hooks/use-api-key";
+import { ApiError } from "@/lib/api";
import "@/index.css";
+const queryClient = new QueryClient({
+ queryCache: new QueryCache({
+ onError: (error) => {
+ if (error instanceof ApiError && error.status === 401) {
+ toast.error("invalid or missing API key");
+ requestApiKey();
+ }
+ },
+ }),
+ defaultOptions: {
+ queries: {
+ // client errors won't fix themselves on retry
+ retry: (failureCount, error) => {
+ if (
+ error instanceof ApiError &&
+ [401, 403, 404].includes(error.status)
+ ) {
+ return false;
+ }
+ return failureCount < 2;
+ },
+ },
+ },
+});
+
createRoot(document.getElementById("root")!).render(
-
+
+
+
+
+
+
,
);