Task e5133d0f: CSV export of the filtered range (#209)
This commit was merged in pull request #209.
This commit is contained in:
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||||
|
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);
|
fireEvent.click(csv);
|
||||||
expect(fetchMock.mock.calls.length).toBe(before);
|
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 () => {
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user