Task e5133d0f: CSV export of the filtered range (#209)

This commit was merged in pull request #209.
This commit is contained in:
2026-09-23 23:33:37 -04:00
parent bc3e64b4f8
commit 75b2ac888a
4 changed files with 291 additions and 12 deletions
+38 -1
View File
@@ -3,7 +3,7 @@
* append-only event log over a closed due-date range for one filter. * append-only event log over a closed due-date range for one filter.
*/ */
import { apiFetch } from "./client"; import { apiFetch } from "./client";
import { readJson } from "./definitions"; import { ApiRequestError, readJson } from "./definitions";
export type HistoryFilter = "all" | "reversals" | "missed"; export type HistoryFilter = "all" | "reversals" | "missed";
@@ -46,3 +46,40 @@ export async function fetchHistory(
const params = new URLSearchParams({ start, end, filter }); const params = new URLSearchParams({ start, end, filter });
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`)); return readJson<HistoryResponse>(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<HistoryCsv> {
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<never>(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`,
};
}
+31
View File
@@ -48,6 +48,37 @@
cursor: pointer; 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). */ /* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.history-scroll { .history-scroll {
display: flex; display: flex;
+162 -5
View File
@@ -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 { function routeFetch(url: string): Response {
const parsed = new URL(url, "http://x"); 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")) { if (!parsed.pathname.endsWith("/api/v1/admin/history")) {
return jsonResponse({ detail: "unexpected" }, 500); return jsonResponse({ detail: "unexpected" }, 500);
} }
@@ -229,15 +248,153 @@ describe("HistoryTab", () => {
expect(screen.getAllByTestId("history-row")).toHaveLength(5); 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(); await renderReady();
expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row."); 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); expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE);
});
const csv = screen.getByRole("button", { name: "CSV" }); describe("CSV export", () => {
const before = fetchMock.mock.calls.length; let createObjectURL: ReturnType<typeof vi.fn>;
fireEvent.click(csv); let revokeObjectURL: ReturnType<typeof vi.fn>;
expect(fetchMock.mock.calls.length).toBe(before); 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<string, unknown>;
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<string, string>).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<Response>((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 () => { it("rows are read-only: no row carries a control", async () => {
+60 -6
View File
@@ -4,9 +4,9 @@
* Rows are read-only: events are never edited or deleted, and a reversal is * Rows are read-only: events are never edited or deleted, and a reversal is
* its own row (D-005). * its own row (D-005).
*/ */
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client"; 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 { localTodayIso } from "../schedule/cycle";
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format"; import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
import { DownloadGlyph, LockGlyph } from "./glyphs"; 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 ( return (
<header className="history-header"> <header className="history-header">
<div> <div>
<h2 className="history-title">History</h2> <h2 className="history-title">History</h2>
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p> <p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
</div> </div>
{/* TODO(e5133d0f): export the filtered range as CSV; inert until then. */} <button
<button type="button" className="history-csv" data-testid="history-csv"> type="button"
className="history-csv"
data-testid="history-csv"
disabled={exporting}
aria-busy={exporting}
onClick={onExport}
>
<DownloadGlyph /> <DownloadGlyph />
CSV CSV
</button> </button>
@@ -140,8 +162,32 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
const [filter, setFilter] = useState<HistoryFilter>("all"); const [filter, setFilter] = useState<HistoryFilter>("all");
const [state, setState] = useState<LoadState>({ kind: "loading" }); const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const exportingRef = useRef(false);
const start = shiftIso(today, -(RANGE_DAYS - 1)); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setState({ kind: "loading" }); setState({ kind: "loading" });
@@ -199,12 +245,20 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
return ( return (
<div className="history-screen"> <div className="history-screen">
<ScreenHeader /> <ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
<div <div
className="history-scroll" className="history-scroll"
data-testid="history-scroll" data-testid="history-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }} style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
> >
{exportError !== null ? (
<div className="history-export-error" role="alert" data-testid="history-export-error">
<span>{exportError}</span>
<button type="button" className="history-export-dismiss" onClick={() => setExportError(null)}>
Dismiss
</button>
</div>
) : null}
{state.kind === "ready" ? <StatRow stats={state.data.stats} /> : null} {state.kind === "ready" ? <StatRow stats={state.data.stats} /> : null}
<div className="history-chips" role="group" aria-label="Filter events"> <div className="history-chips" role="group" aria-label="Filter events">
{FILTERS.map((item) => ( {FILTERS.map((item) => (