From 0dfab6a92ed012a85eaad75ff55fe284d2765489 Mon Sep 17 00:00:00 2001 From: Scr4tch587 Date: Sun, 12 Jul 2026 14:50:49 -0400 Subject: [PATCH 1/3] feat: build trigger ui --- frontend/src/components/build-panel.tsx | 148 +++++++++++++++++++++ frontend/src/components/dataset-detail.tsx | 3 + 2 files changed, 151 insertions(+) create mode 100644 frontend/src/components/build-panel.tsx diff --git a/frontend/src/components/build-panel.tsx b/frontend/src/components/build-panel.tsx new file mode 100644 index 0000000..c8863c7 --- /dev/null +++ b/frontend/src/components/build-panel.tsx @@ -0,0 +1,148 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import type { BuildResponse, DataRow, DryRunBuildResponse } from "@/lib/api"; + +import { DataTable } from "@/components/data-table"; +import { JsonModal } from "@/components/json-modal"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { dryRunBuild, triggerBuild } from "@/lib/api"; +import { toISODate } from "@/lib/format"; + +// dry-run output is previewed inline, not paginated; cap it to keep the dom sane +const DRY_RUN_PREVIEW_LIMIT = 50; + +// builds are heavier than reads, so default to a month instead of the 5-year browse window +function defaultBuildRange(): { start: string; end: string } { + const end = new Date(); + const start = new Date(end); + start.setDate(start.getDate() - 30); + return { start: toISODate(start), end: toISODate(end) }; +} + +interface BuildPanelProps { + name: string; + version: string; +} + +/** + * triggers builds for a date range. real builds write missing timestamps to + * the db and refresh the data table; dry runs preview the produced rows + * without writing anything. + */ +export function BuildPanel({ name, version }: BuildPanelProps) { + const [startDate, setStartDate] = useState(() => defaultBuildRange().start); + const [endDate, setEndDate] = useState(() => defaultBuildRange().end); + const [dryRun, setDryRun] = useState(false); + const [dryRunRows, setDryRunRows] = useState(null); + const [selectedRow, setSelectedRow] = useState | null>(null); + + const queryClient = useQueryClient(); + + const mutation = useMutation< + BuildResponse | DryRunBuildResponse, + Error, + { dryRun: boolean } + >({ + mutationFn: (opts) => + opts.dryRun + ? dryRunBuild(name, version, startDate, endDate) + : triggerBuild(name, version, startDate, endDate), + onSuccess: (result) => { + if ("rows" in result) { + setDryRunRows(result.rows); + toast.success(`dry run produced ${result.rows.length} timestamps`); + return; + } + setDryRunRows(null); + toast.success(`build complete for ${name}/${version}`); + // refetch the data table and the has_data dot on the list view + void queryClient.invalidateQueries({ queryKey: ["data", name, version] }); + void queryClient.invalidateQueries({ queryKey: ["datasets"] }); + }, + }); + + const previewRows = dryRunRows?.slice(0, DRY_RUN_PREVIEW_LIMIT); + + return ( +
+

build data

+
+
+ + setStartDate(e.target.value)} + className="w-40" + /> +
+
+ + setEndDate(e.target.value)} + className="w-40" + /> +
+
+ setDryRun(checked === true)} + /> + +
+ +
+ + {mutation.isPending && ( +

+ builds run synchronously on the server — large ranges can take a + while... +

+ )} + + {mutation.isError && ( +
+

{mutation.error.message}

+
+ )} + + {previewRows && dryRunRows && ( +
+

+ dry run produced {dryRunRows.length} timestamps — nothing was + written to the database + {dryRunRows.length > DRY_RUN_PREVIEW_LIMIT && + ` (showing first ${DRY_RUN_PREVIEW_LIMIT})`} +

+ +
+ )} + + setSelectedRow(null)} /> +
+ ); +} diff --git a/frontend/src/components/dataset-detail.tsx b/frontend/src/components/dataset-detail.tsx index 1d7c9f4..c0f5853 100644 --- a/frontend/src/components/dataset-detail.tsx +++ b/frontend/src/components/dataset-detail.tsx @@ -1,6 +1,7 @@ import { ArrowLeftIcon } from "lucide-react"; import { useMemo, useState } from "react"; +import { BuildPanel } from "@/components/build-panel"; import { DataTable } from "@/components/data-table"; import { JsonModal } from "@/components/json-modal"; import { Button } from "@/components/ui/button"; @@ -54,6 +55,8 @@ export function DatasetDetail({ name, version, onBack }: DatasetDetailProps) { + + {isPending && (

loading data...

)} From b7c078a4070598bc1b95c66c146d5ec36e3dd25d Mon Sep 17 00:00:00 2001 From: Scr4tch587 Date: Sun, 12 Jul 2026 14:51:34 -0400 Subject: [PATCH 2/3] docs: document build trigger in frontend spec --- dev-docs/SPEC-frontend.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/dev-docs/SPEC-frontend.md b/dev-docs/SPEC-frontend.md index 05c2360..7104d97 100644 --- a/dev-docs/SPEC-frontend.md +++ b/dev-docs/SPEC-frontend.md @@ -44,7 +44,8 @@ frontend/src/ components/ ui/ # shadcn-generated components (button, table, dialog, form, ...) dataset-list.tsx # landing page: all datasets + has_data dot - dataset-detail.tsx # detail view: paginated data table (50/page, newest first) + dataset-detail.tsx # detail view: build panel + paginated data table (50/page, newest first) + build-panel.tsx # build trigger: date range, dry-run toggle, dry-run row preview data-table.tsx # dynamic-column table with rowspan for multi-entry timestamps json-view.tsx # recursive jsx syntax highlighter (no innerHTML) json-modal.tsx # row details in a shadcn Dialog @@ -79,9 +80,11 @@ All API calls go through `lib/api.ts` and use the `/api/v1` prefix. - **Base URL**: `VITE_API_BASE_URL` (baked in at build time, set for Pages builds) or relative `/api` in dev, where Vite proxies to localhost:3000 - `fetchDatasets()` -- `GET /api/v1/datasets` - `fetchData(name, version, start, end)` -- `GET /api/v1/data/{name}/{version}?start=...&end=...&build-data=false` -- `proposeDataset(payload)` -- `POST /api/v1/datasets` (the only write; the backend opens a dataset-proposal PR, see "Dataset proposals endpoint" in `SPEC-backend.md`) +- `proposeDataset(payload)` -- `POST /api/v1/datasets` (the backend opens a dataset-proposal PR, see "Dataset proposals endpoint" in `SPEC-backend.md`) +- `triggerBuild(name, version, start, end)` -- `POST /api/v1/build/{name}/{version}?start=...&end=...` (real build: writes missing rows to the DB) +- `dryRunBuild(name, version, start, end)` -- same endpoint with `dry-run=true` (in-memory build, returns the produced rows, writes nothing) -Browsing never triggers builds (`build-data=false` always). Both 200 and 206 responses are treated as valid (206 indicates partial/incomplete data). Failures throw `ApiError` carrying the HTTP status **and the backend's `detail` message** when present, so server-side validation errors render verbatim in forms. 401s from both queries and mutations funnel through one handler (`QueryCache` + `MutationCache` `onError`). +Browsing never triggers builds (`build-data=false` always); builds only happen explicitly through the build panel. Both 200 and 206 responses are treated as valid (206 indicates partial/incomplete data). Failures throw `ApiError` carrying the HTTP status **and the backend's `detail` message** when present, so server-side validation errors render verbatim in forms. 401s from both queries and mutations funnel through one handler (`QueryCache` + `MutationCache` `onError`). ### API-key auth @@ -116,6 +119,16 @@ The backend requires `Authorization: Bearer ` on everything except `/st - **Metadata**: shows returned timestamp count and current page number - **Error handling**: error banner with retry button +### Build panel + +`build-panel.tsx` sits at the top of the dataset detail view and triggers builds via `POST /build` (see "Build behavior" in `SPEC-backend.md`). + +- **Inputs**: start/end date pickers (default: last 30 days — builds are heavier than reads, so the default window is deliberately smaller than the 5-year browse window) and a dry-run checkbox +- **Real build**: on success, shows a toast and invalidates the dataset's data query and the dataset list query, so the table and the `has_data` dot refresh without a reload +- **Dry run**: nothing is written server-side; the produced rows render inline in a `DataTable` (capped at the first 50 timestamps) with the JSON modal available per row, so builder output can be inspected before a real build +- **Pending state**: the button disables and a note warns that builds run synchronously on the server and large ranges can take a while (the browser request stays open for the whole build) +- **Errors**: the backend `detail` message (e.g. 422 "no valid calendar timestamps") renders in an inline error box; 401s go through the central handler + ### Data table `data-table.tsx` renders dataset rows as an HTML table with dynamically derived column headers (computed from the keys of the first data entry). From 19b08fbf9ad2132512289f00bf41c468746b7a2a Mon Sep 17 00:00:00 2001 From: Scr4tch587 Date: Mon, 27 Jul 2026 22:05:01 -0400 Subject: [PATCH 3/3] fix: default to dry run, confirm real builds, guard inverted ranges Co-Authored-By: Claude Opus 5 --- dev-docs/SPEC-frontend.md | 3 ++ frontend/src/components/build-panel.tsx | 72 +++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/dev-docs/SPEC-frontend.md b/dev-docs/SPEC-frontend.md index 7104d97..c1a309d 100644 --- a/dev-docs/SPEC-frontend.md +++ b/dev-docs/SPEC-frontend.md @@ -124,6 +124,9 @@ The backend requires `Authorization: Bearer ` on everything except `/st `build-panel.tsx` sits at the top of the dataset detail view and triggers builds via `POST /build` (see "Build behavior" in `SPEC-backend.md`). - **Inputs**: start/end date pickers (default: last 30 days — builds are heavier than reads, so the default window is deliberately smaller than the 5-year browse window) and a dry-run checkbox +- **Dry run is the default** (checkbox starts checked): the safe, non-writing path is what a stray click gets, and writing to the database is something the user opts into rather than out of +- **Range validation**: the submit button is disabled while the range is inverted (`end < start`, compared as ISO strings) and an inline message says so, instead of sending a nonsense range to the backend. The pickers also carry native `max`/`min` bounds (start ≤ end) so the inverted state is hard to reach in the first place +- **Write confirmation**: a real build opens a confirmation Dialog naming the dataset and range before anything runs — a dry run fires immediately, since it writes nothing - **Real build**: on success, shows a toast and invalidates the dataset's data query and the dataset list query, so the table and the `has_data` dot refresh without a reload - **Dry run**: nothing is written server-side; the produced rows render inline in a `DataTable` (capped at the first 50 timestamps) with the JSON modal available per row, so builder output can be inspected before a real build - **Pending state**: the button disables and a note warns that builds run synchronously on the server and large ranges can take a while (the browser request stays open for the whole build) diff --git a/frontend/src/components/build-panel.tsx b/frontend/src/components/build-panel.tsx index c8863c7..23c44f2 100644 --- a/frontend/src/components/build-panel.tsx +++ b/frontend/src/components/build-panel.tsx @@ -8,6 +8,14 @@ import { DataTable } from "@/components/data-table"; import { JsonModal } from "@/components/json-modal"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { dryRunBuild, triggerBuild } from "@/lib/api"; @@ -32,12 +40,14 @@ interface BuildPanelProps { /** * triggers builds for a date range. real builds write missing timestamps to * the db and refresh the data table; dry runs preview the produced rows - * without writing anything. + * without writing anything. dry run is the default, and a real build needs + * confirmation before it touches the db. */ export function BuildPanel({ name, version }: BuildPanelProps) { const [startDate, setStartDate] = useState(() => defaultBuildRange().start); const [endDate, setEndDate] = useState(() => defaultBuildRange().end); - const [dryRun, setDryRun] = useState(false); + const [dryRun, setDryRun] = useState(true); + const [confirmOpen, setConfirmOpen] = useState(false); const [dryRunRows, setDryRunRows] = useState(null); const [selectedRow, setSelectedRow] = useState

build data

@@ -81,6 +111,7 @@ export function BuildPanel({ name, version }: BuildPanelProps) { id="build-start" type="date" value={startDate} + max={endDate || undefined} onChange={(e) => setStartDate(e.target.value)} className="w-40" /> @@ -91,6 +122,7 @@ export function BuildPanel({ name, version }: BuildPanelProps) { id="build-end" type="date" value={endDate} + min={startDate || undefined} onChange={(e) => setEndDate(e.target.value)} className="w-40" /> @@ -106,8 +138,8 @@ export function BuildPanel({ name, version }: BuildPanelProps) { + {rangeInverted && ( +

+ end date must be on or after the start date +

+ )} + {mutation.isPending && (

builds run synchronously on the server — large ranges can take a @@ -142,6 +180,32 @@ export function BuildPanel({ name, version }: BuildPanelProps) { )} +

+ + + run a real build? + + this builds {name}/{version} from {startDate} to {endDate} and + writes the produced rows to the database. already-built timestamps + are skipped, but new rows can't be removed from the ui. tick + “dry run” instead to preview the output first. + + + + + + + + + setSelectedRow(null)} /> );