diff --git a/admin/src/api/history.ts b/admin/src/api/history.ts index 17cb902..eeecda8 100644 --- a/admin/src/api/history.ts +++ b/admin/src/api/history.ts @@ -3,7 +3,7 @@ * append-only event log over a closed due-date range for one filter. */ import { apiFetch } from "./client"; -import { readJson } from "./definitions"; +import { ApiRequestError, readJson } from "./definitions"; export type HistoryFilter = "all" | "reversals" | "missed"; @@ -46,3 +46,40 @@ export async function fetchHistory( const params = new URLSearchParams({ start, end, filter }); return readJson(await apiFetch(`${HISTORY_PATH}?${params.toString()}`)); } + +export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv"; + +export interface HistoryCsv { + blob: Blob; + filename: string; +} + +/** The `filename` from a Content-Disposition header, or null. */ +export function csvFilename(disposition: string | null): string | null { + const match = disposition?.match(/filename="?([^";]+)"?/i); + return match ? match[1].trim() : null; +} + +/** + * The CSV export of the same range and filter as `fetchHistory`. Goes through + * apiFetch so the admin token travels in the Authorization header. + */ +export async function exportHistoryCsv( + filter: HistoryFilter, + start: string, + end: string, +): Promise { + const params = new URLSearchParams({ start, end, filter }); + const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`); + if (!response.ok) { + // readJson raises ApiRequestError with the API's `detail` for a non-OK response. + await readJson(response); + throw new ApiRequestError(response.status, null); + } + return { + blob: await response.blob(), + filename: + csvFilename(response.headers.get("Content-Disposition")) ?? + `nestquest-history-${start}-to-${end}.csv`, + }; +} diff --git a/admin/src/history/HistoryTab.css b/admin/src/history/HistoryTab.css index 5969554..d362b9b 100644 --- a/admin/src/history/HistoryTab.css +++ b/admin/src/history/HistoryTab.css @@ -48,6 +48,37 @@ cursor: pointer; } +.history-csv:disabled { + cursor: progress; + opacity: 0.6; +} + +/* Non-blocking CSV export failure; the tab stays usable. */ +.history-export-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: var(--nq-a-density-row-pad); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-danger-bg); + color: var(--nq-a-danger-strong); + font-size: 13px; + font-weight: 600; +} + +.history-export-dismiss { + flex: none; + padding: 4px 8px; + border: none; + background: none; + color: inherit; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + /* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ .history-scroll { display: flex; diff --git a/admin/src/history/HistoryTab.test.tsx b/admin/src/history/HistoryTab.test.tsx index 07a2344..a828e09 100644 --- a/admin/src/history/HistoryTab.test.tsx +++ b/admin/src/history/HistoryTab.test.tsx @@ -93,8 +93,27 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +const CSV_BODY = "occurred_at,event_type\n2026-09-23T14:04:00Z,completed\n"; + +function csvResponse(filter: string, start: string, end: string): Response { + return new Response(`${filter}\n${CSV_BODY}`, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="nestquest-history-${start}-to-${end}.csv"`, + }, + }); +} + function routeFetch(url: string): Response { const parsed = new URL(url, "http://x"); + if (parsed.pathname.endsWith("/api/v1/admin/history.csv")) { + return csvResponse( + parsed.searchParams.get("filter") ?? "", + parsed.searchParams.get("start") ?? "", + parsed.searchParams.get("end") ?? "", + ); + } if (!parsed.pathname.endsWith("/api/v1/admin/history")) { return jsonResponse({ detail: "unexpected" }, 500); } @@ -229,15 +248,153 @@ describe("HistoryTab", () => { expect(screen.getAllByTestId("history-row")).toHaveLength(5); }); - it("shows the append-only footer and an inert CSV button", async () => { + it("shows the append-only footer", async () => { await renderReady(); expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row."); expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE); + }); - const csv = screen.getByRole("button", { name: "CSV" }); - const before = fetchMock.mock.calls.length; - fireEvent.click(csv); - expect(fetchMock.mock.calls.length).toBe(before); + describe("CSV export", () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + let clicked: { href: string; download: string; attached: boolean }[]; + + beforeEach(() => { + createObjectURL = vi.fn(() => "blob:nestquest/csv-1"); + revokeObjectURL = vi.fn(); + // jsdom has no object URLs; install stubs and remove them afterwards. + Object.assign(URL, { createObjectURL, revokeObjectURL }); + clicked = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { + clicked.push({ + href: this.getAttribute("href") ?? "", + download: this.download, + attached: document.body.contains(this), + }); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + const stubbed = URL as unknown as Record; + delete stubbed.createObjectURL; + delete stubbed.revokeObjectURL; + }); + + function csvCalls(): [URL, RequestInit][] { + return fetchMock.mock.calls + .map(([url, init]) => [new URL(String(url), "http://x"), init] as [URL, RequestInit]) + .filter(([url]) => url.pathname === "/api/v1/admin/history.csv"); + } + + it("requests the CSV for the active filter and displayed range with the Bearer token", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "CSV" })); + await waitFor(() => expect(clicked).toHaveLength(1)); + + const calls = csvCalls(); + expect(calls).toHaveLength(1); + const [url, init] = calls[0]; + expect(url.searchParams.get("filter")).toBe("all"); + expect(url.searchParams.get("start")).toBe("2026-09-10"); + expect(url.searchParams.get("end")).toBe(TODAY); + expect((init.headers as Record).Authorization).toBe("Bearer at-123"); + // The token never travels in the URL. + expect(url.toString()).not.toContain("at-123"); + }); + + it("offers the returned CSV for download, then revokes the URL and removes the anchor", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "CSV" })); + await waitFor(() => expect(clicked).toHaveLength(1)); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(await blob.text()).toBe(`all\n${CSV_BODY}`); + expect(clicked[0]).toEqual({ + href: "blob:nestquest/csv-1", + download: "nestquest-history-2026-09-10-to-2026-09-23.csv", + attached: true, + }); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:nestquest/csv-1"); + expect(document.querySelector("a[download]")).toBeNull(); + expect(screen.queryByTestId("history-export-error")).toBeNull(); + }); + + it("falls back to the range filename without a Content-Disposition header", async () => { + fetchMock.mockImplementation((url: string) => + Promise.resolve( + new URL(url, "http://x").pathname.endsWith(".csv") + ? new Response(CSV_BODY, { status: 200, headers: { "Content-Type": "text/csv" } }) + : routeFetch(url), + ), + ); + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "CSV" })); + await waitFor(() => expect(clicked).toHaveLength(1)); + expect(clicked[0].download).toBe("nestquest-history-2026-09-10-to-2026-09-23.csv"); + }); + + it("switching the filter chip then exporting uses the new filter", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Reversals" })); + await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1)); + + fireEvent.click(screen.getByRole("button", { name: "CSV" })); + await waitFor(() => expect(clicked).toHaveLength(1)); + const calls = csvCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0].searchParams.get("filter")).toBe("reversals"); + expect(calls[0][0].searchParams.get("start")).toBe("2026-09-10"); + expect(calls[0][0].searchParams.get("end")).toBe(TODAY); + }); + + it("a failed export shows a non-blocking error and does not download", async () => { + fetchMock.mockImplementation((url: string) => + Promise.resolve( + new URL(url, "http://x").pathname.endsWith(".csv") + ? jsonResponse({ detail: "boom" }, 500) + : routeFetch(url), + ), + ); + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "CSV" })); + + const error = await screen.findByTestId("history-export-error"); + expect(error.textContent).toContain("The CSV export failed"); + expect(createObjectURL).not.toHaveBeenCalled(); + expect(clicked).toHaveLength(0); + // The tab stays usable: rows remain, the button is re-enabled, chips still work. + expect(screen.getAllByTestId("history-row")).toHaveLength(5); + expect((screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(screen.getByRole("button", { name: "Reversals" })); + await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1)); + + fireEvent.click(within(error).getByRole("button", { name: "Dismiss" })); + expect(screen.queryByTestId("history-export-error")).toBeNull(); + }); + + it("the button is disabled while the request is in flight (no double-submit)", async () => { + let release: (response: Response) => void = () => {}; + fetchMock.mockImplementation((url: string) => + new URL(url, "http://x").pathname.endsWith(".csv") + ? new Promise((resolve) => { + release = resolve; + }) + : Promise.resolve(routeFetch(url)), + ); + await renderReady(); + const csv = screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement; + fireEvent.click(csv); + await waitFor(() => expect(csv.disabled).toBe(true)); + fireEvent.click(csv); + expect(csvCalls()).toHaveLength(1); + + release(csvResponse("all", "2026-09-10", TODAY)); + await waitFor(() => expect(clicked).toHaveLength(1)); + await waitFor(() => expect(csv.disabled).toBe(false)); + expect(csvCalls()).toHaveLength(1); + }); }); it("rows are read-only: no row carries a control", async () => { diff --git a/admin/src/history/HistoryTab.tsx b/admin/src/history/HistoryTab.tsx index 093f0bb..f929166 100644 --- a/admin/src/history/HistoryTab.tsx +++ b/admin/src/history/HistoryTab.tsx @@ -4,9 +4,9 @@ * Rows are read-only: events are never edited or deleted, and a reversal is * its own row (D-005). */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ApiForbiddenError } from "../api/client"; -import { fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history"; +import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history"; import { localTodayIso } from "../schedule/cycle"; import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format"; import { DownloadGlyph, LockGlyph } from "./glyphs"; @@ -70,15 +70,37 @@ async function loadHistory(filter: HistoryFilter, start: string, end: string): P }; } -function ScreenHeader() { +/** Offer a Blob as a file download without navigating the page. */ +export function offerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.style.display = "none"; + document.body.appendChild(anchor); + try { + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } +} + +function ScreenHeader({ exporting, onExport }: { exporting: boolean; onExport: () => void }) { return (

History

Append-only event log · last {RANGE_DAYS} days

- {/* TODO(e5133d0f): export the filtered range as CSV; inert until then. */} - @@ -140,8 +162,32 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string const [filter, setFilter] = useState("all"); const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const exportingRef = useRef(false); const start = shiftIso(today, -(RANGE_DAYS - 1)); + /** Download the CSV for the active filter over the displayed range. */ + async function exportCsv() { + if (exportingRef.current) return; + exportingRef.current = true; + setExporting(true); + setExportError(null); + try { + const { blob, filename } = await exportHistoryCsv(filter, start, today); + offerDownload(blob, filename); + } catch (error: unknown) { + setExportError( + error instanceof ApiForbiddenError + ? error.message + : "The CSV export failed. Check your connection and try again.", + ); + } finally { + exportingRef.current = false; + setExporting(false); + } + } + useEffect(() => { let cancelled = false; setState({ kind: "loading" }); @@ -199,12 +245,20 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string return (
- + void exportCsv()} />
+ {exportError !== null ? ( +
+ {exportError} + +
+ ) : null} {state.kind === "ready" ? : null}
{FILTERS.map((item) => (