Feature 19: Admin PWA (#187-#210) (#211)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #211.
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
|
||||
the 390x844 reference; on wider viewports the column is capped and centred. */
|
||||
|
||||
.history-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.history-sub {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-csv {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
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;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
/* Stat row */
|
||||
.history-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-stat {
|
||||
padding: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.history-stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.history-stat-value--on-time {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.history-stat-value--missed {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.history-stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.history-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.history-chip {
|
||||
flex: none;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-chip--active {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Day-grouped event rows */
|
||||
.history-day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.history-section-label {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.history-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.history-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.history-dot {
|
||||
flex: none;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.history-dot--completed {
|
||||
background: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.history-dot--uncompleted {
|
||||
background: var(--nq-a-reversal);
|
||||
}
|
||||
|
||||
.history-dot--missed {
|
||||
background: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.history-event-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-event-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-event-meta {
|
||||
margin-top: 1px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-event-time {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Append-only footer strip */
|
||||
.history-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-page);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-note-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.history-status {
|
||||
padding: 24px 13px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
text-align: center;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.history-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.history-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.history-retry {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
|
||||
import type { HistoryFilter, HistoryRow } from "../api/history";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
|
||||
const TODAY = "2026-09-23";
|
||||
|
||||
function row(overrides: Partial<HistoryRow>): HistoryRow {
|
||||
return {
|
||||
occurred_at: null,
|
||||
event_type: "completed",
|
||||
child_name: "Declan",
|
||||
child_id: 1,
|
||||
quest_title: "Brush teeth",
|
||||
instance_id: 1,
|
||||
window: "morning",
|
||||
due_date: TODAY,
|
||||
due_time: null,
|
||||
actor: "panel (Declan)",
|
||||
was_on_time: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// America/Los_Angeles is UTC-7 in September.
|
||||
const BRUSH = row({
|
||||
instance_id: 1,
|
||||
occurred_at: "2026-09-23T14:04:00Z", // 7:04 AM
|
||||
due_time: "07:30",
|
||||
});
|
||||
const BED_DONE = row({
|
||||
instance_id: 2,
|
||||
quest_title: "Make bed",
|
||||
child_name: "Maeve",
|
||||
child_id: 2,
|
||||
occurred_at: "2026-09-23T14:50:00Z", // 7:50 AM
|
||||
actor: "panel (Maeve)",
|
||||
});
|
||||
const BED_UNDONE = row({
|
||||
instance_id: 2,
|
||||
event_type: "uncompleted",
|
||||
quest_title: "Make bed",
|
||||
child_name: "Maeve",
|
||||
child_id: 2,
|
||||
occurred_at: "2026-09-23T15:10:00Z", // 8:10 AM
|
||||
actor: "joshua (admin)",
|
||||
was_on_time: null,
|
||||
});
|
||||
const CAT_LATE = row({
|
||||
instance_id: 3,
|
||||
quest_title: "Feed cat",
|
||||
due_date: "2026-09-22",
|
||||
due_time: "07:00",
|
||||
occurred_at: "2026-09-22T14:22:00Z", // 7:22 AM, 22 min after due
|
||||
was_on_time: false,
|
||||
});
|
||||
// 7:30 PM local on Sep 22 — already Sep 23 in UTC.
|
||||
const HOMEWORK_LATE = row({
|
||||
instance_id: 4,
|
||||
quest_title: "Homework",
|
||||
child_name: "Rory",
|
||||
child_id: 3,
|
||||
due_date: "2026-09-22",
|
||||
due_time: null,
|
||||
occurred_at: "2026-09-23T02:30:00Z",
|
||||
actor: "panel (Rory)",
|
||||
was_on_time: false,
|
||||
});
|
||||
const PLANTS_MISSED = row({
|
||||
instance_id: 5,
|
||||
event_type: "missed",
|
||||
quest_title: "Water plants",
|
||||
child_name: "Rory",
|
||||
child_id: 3,
|
||||
due_date: "2026-09-21",
|
||||
due_time: "18:00",
|
||||
actor: "nightly sweep",
|
||||
was_on_time: null,
|
||||
});
|
||||
|
||||
// The API's order: due_date, then instance, then event id.
|
||||
const ROWS: Record<HistoryFilter, HistoryRow[]> = {
|
||||
all: [CAT_LATE, HOMEWORK_LATE, BRUSH, BED_DONE, BED_UNDONE],
|
||||
reversals: [BED_UNDONE],
|
||||
missed: [PLANTS_MISSED],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const filter = parsed.searchParams.get("filter") as HistoryFilter;
|
||||
const rows = ROWS[filter];
|
||||
return jsonResponse({
|
||||
filter,
|
||||
start: parsed.searchParams.get("start"),
|
||||
end: parsed.searchParams.get("end"),
|
||||
count: rows.length,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function historyCalls(): URL[] {
|
||||
return fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x"));
|
||||
}
|
||||
|
||||
function metas(section: HTMLElement): string[] {
|
||||
return within(section)
|
||||
.getAllByTestId("history-meta")
|
||||
.map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
async function renderReady() {
|
||||
render(<HistoryTab today={TODAY} />);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
}
|
||||
|
||||
describe("HistoryTab", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn((url: string) => Promise.resolve(routeFetch(url)));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the header and queries the last 14 days with the Bearer token", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
|
||||
expect(screen.getByText("Append-only event log · last 14 days")).toBeTruthy();
|
||||
|
||||
const calls = historyCalls();
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
for (const url of calls) {
|
||||
expect(url.pathname).toBe("/api/v1/admin/history");
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
||||
}
|
||||
expect(calls.map((url) => url.searchParams.get("filter")).sort()).toEqual(["all", "missed"]);
|
||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("stat row shows On time %, Completed and Missed for the period", async () => {
|
||||
await renderReady();
|
||||
// 4 completions (2 on time) in `all`; 1 derived missed row.
|
||||
expect(screen.getByTestId("stat-on-time").textContent).toBe("50%On time");
|
||||
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
|
||||
expect(screen.getByTestId("stat-missed").textContent).toBe("1Missed");
|
||||
});
|
||||
|
||||
it("On time shows — when the period has no completions", () => {
|
||||
expect(computeStats([BED_UNDONE], [])).toEqual({ onTimePct: null, completed: 0, missed: 0 });
|
||||
});
|
||||
|
||||
it("groups rows by local day, newest first, with event type, actor and on-time meta", async () => {
|
||||
await renderReady();
|
||||
|
||||
const today = screen.getByTestId(`day-${TODAY}`);
|
||||
expect(within(today).getByRole("heading").textContent).toBe("Today");
|
||||
expect(metas(today)).toEqual([
|
||||
"uncompleted · joshua (admin)",
|
||||
"completed · panel (Maeve) · on time",
|
||||
"completed · panel (Declan) · on time",
|
||||
]);
|
||||
expect(within(today).getAllByText("Make bed · Maeve")).toHaveLength(2);
|
||||
expect(within(today).getByText("8:10 AM")).toBeTruthy();
|
||||
|
||||
// The 7:30 PM completion is Sep 23 in UTC but Sep 22 locally.
|
||||
const yesterday = screen.getByTestId("day-2026-09-22");
|
||||
expect(within(yesterday).getByRole("heading").textContent).toBe("Yesterday");
|
||||
expect(metas(yesterday)).toEqual([
|
||||
"completed · panel (Rory) · late",
|
||||
"completed · panel (Declan) · late 22 min",
|
||||
]);
|
||||
expect(within(yesterday).getByText("7:30 PM")).toBeTruthy();
|
||||
|
||||
const dots = screen.getAllByTestId("history-dot").map((el) => el.className);
|
||||
expect(dots[0]).toContain("history-dot--uncompleted");
|
||||
expect(dots[1]).toContain("history-dot--completed");
|
||||
});
|
||||
|
||||
it("each filter chip re-queries the API with its filter and re-renders", async () => {
|
||||
await renderReady();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
await waitFor(() =>
|
||||
expect(historyCalls().some((url) => url.searchParams.get("filter") === "reversals")).toBe(true),
|
||||
);
|
||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
||||
expect(screen.getByTestId("history-meta").textContent).toBe("uncompleted · joshua (admin)");
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Missed" }));
|
||||
const missedDay = await screen.findByTestId("day-2026-09-21");
|
||||
expect(within(missedDay).getByRole("heading").textContent).toBe("Mon, Sep 21");
|
||||
expect(metas(missedDay)).toEqual(["missed · nightly sweep"]);
|
||||
expect(within(missedDay).getByTestId("history-dot").className).toContain("history-dot--missed");
|
||||
expect(within(missedDay).getByText("6:00 PM")).toBeTruthy();
|
||||
// Stats stay the period's numbers whichever chip is selected.
|
||||
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
|
||||
|
||||
fetchMock.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "All events" }));
|
||||
await screen.findByTestId(`day-${TODAY}`);
|
||||
expect(historyCalls().map((url) => url.searchParams.get("filter"))).toContain("all");
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe("CSV export", () => {
|
||||
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);
|
||||
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 () => {
|
||||
await renderReady();
|
||||
for (const el of screen.getAllByTestId("history-row")) {
|
||||
expect(within(el).queryByRole("button")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("a failed fetch shows the error state and Try again reloads", async () => {
|
||||
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ detail: "boom" }, 500)));
|
||||
render(<HistoryTab today={TODAY} />);
|
||||
|
||||
const error = await screen.findByTestId("history-error");
|
||||
expect(error.textContent).toContain("The history could not be loaded");
|
||||
expect(screen.getByTestId("history-footer")).toBeTruthy();
|
||||
|
||||
fetchMock.mockImplementation((url: string) => Promise.resolve(routeFetch(url)));
|
||||
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
|
||||
await screen.findByTestId("stat-on-time");
|
||||
expect(screen.queryByTestId("history-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("the scroll column ends with 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HistoryTab live updates", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let streams: TestStream[];
|
||||
|
||||
function historyRequests(): URL[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([url]) => new URL(String(url), "http://x"))
|
||||
.filter((url) => url.pathname === "/api/v1/admin/history");
|
||||
}
|
||||
|
||||
const PAYLOAD = {
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
instance_id: 2,
|
||||
quest_title: "Make bed",
|
||||
window: "morning",
|
||||
due_date: TODAY,
|
||||
due_time: null,
|
||||
occurred_at: "2026-09-23T15:10:00Z",
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
streams = [];
|
||||
fetchMock = vi.fn((url: string) => {
|
||||
if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
|
||||
const stream = testStream();
|
||||
streams.push(stream);
|
||||
return Promise.resolve(stream.response);
|
||||
}
|
||||
return Promise.resolve(routeFetch(url));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("a quest transition refetches the active filter; a day-complete does not", async () => {
|
||||
render(
|
||||
<TransitionsProvider>
|
||||
<HistoryTab today={TODAY} />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
await waitFor(() =>
|
||||
expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const before = historyRequests().length;
|
||||
|
||||
streams[0].push(
|
||||
frame("nestquest_child_day_complete", {
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
quests_due: 4,
|
||||
quests_completed: 4,
|
||||
occurred_at: "2026-09-23T15:00:00Z",
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(historyRequests().length).toBe(before);
|
||||
|
||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||
|
||||
await waitFor(() => expect(historyRequests().length).toBe(before + 3));
|
||||
const refetched = historyRequests().slice(before);
|
||||
// One refetch (reversals + the two stats queries), same range and filter.
|
||||
expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
|
||||
"all",
|
||||
"missed",
|
||||
"reversals",
|
||||
]);
|
||||
for (const url of refetched) {
|
||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(historyRequests().length).toBe(before + 3);
|
||||
expect(screen.queryByTestId("history-loading")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
// Rows stay read-only (D-005): no row carries a control.
|
||||
for (const row of screen.getAllByTestId("history-row")) {
|
||||
expect(within(row).queryByRole("button")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
describe("a filter change interleaved with a quest transition", () => {
|
||||
// Both land before the load effect runs, so one load serves both.
|
||||
async function interleave(order: "sse-first" | "chip-first") {
|
||||
await act(async () => {
|
||||
const click = () => fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||
if (order === "chip-first") click();
|
||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
if (order === "sse-first") click();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderLive() {
|
||||
render(
|
||||
<TransitionsProvider>
|
||||
<HistoryTab today={TODAY} />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
}
|
||||
|
||||
function titles(): string[] {
|
||||
return screen.getAllByTestId("history-row").map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
for (const order of ["sse-first", "chip-first"] as const) {
|
||||
it(`${order}: shows loading, then only the active filter's rows`, async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const route = fetchMock.getMockImplementation()!;
|
||||
await renderLive();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.searchParams.get("filter") === "reversals") {
|
||||
return gate.then(() => route(url));
|
||||
}
|
||||
return route(url);
|
||||
});
|
||||
|
||||
await interleave(order);
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
// The previous filter's rows never show under the new chip.
|
||||
expect(screen.getByTestId("history-loading")).toBeTruthy();
|
||||
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
|
||||
|
||||
release();
|
||||
await screen.findByTestId("stat-on-time");
|
||||
expect(titles()).toHaveLength(1);
|
||||
expect(titles()[0]).toContain("Make bed · Maeve");
|
||||
expect(screen.getAllByTestId("history-dot")[0].className).toContain("history-dot--uncompleted");
|
||||
});
|
||||
|
||||
it(`${order}: a failed filter-change load shows the error state, not stale rows`, async () => {
|
||||
const route = fetchMock.getMockImplementation()!;
|
||||
await renderLive();
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const parsed = new URL(url, "http://x");
|
||||
if (parsed.pathname === "/api/v1/admin/history") {
|
||||
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
||||
}
|
||||
return route(url);
|
||||
});
|
||||
|
||||
await interleave(order);
|
||||
const error = await screen.findByTestId("history-error");
|
||||
expect(error.textContent).toContain("The history could not be loaded");
|
||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
|
||||
expect(screen.queryByTestId("stat-on-time")).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("a failed transition refresh keeps the active filter's rows on screen", async () => {
|
||||
render(
|
||||
<TransitionsProvider>
|
||||
<HistoryTab today={TODAY} />
|
||||
</TransitionsProvider>,
|
||||
);
|
||||
await screen.findByTestId("stat-on-time");
|
||||
await waitFor(() => expect(streams).toHaveLength(1));
|
||||
const route = fetchMock.getMockImplementation()!;
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (new URL(url, "http://x").pathname === "/api/v1/admin/history") {
|
||||
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
||||
}
|
||||
return route(url);
|
||||
});
|
||||
const before = historyRequests().length;
|
||||
|
||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||
// The "all" filter plus the missed stats query.
|
||||
await waitFor(() => expect(historyRequests().length).toBe(before + 2));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(screen.queryByTestId("history-error")).toBeNull();
|
||||
expect(screen.queryByTestId("history-loading")).toBeNull();
|
||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* History tab — the append-only event log (design/ADMIN-SPEC.md §5).
|
||||
*
|
||||
* Rows are read-only: events are never edited or deleted, and a reversal is
|
||||
* its own row (D-005).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import type { TransitionEvent } from "../api/events";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
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";
|
||||
import "./HistoryTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const RANGE_DAYS = 14;
|
||||
|
||||
export const FOOTER_NOTE = "Events are never edited or deleted. A reversal is its own row.";
|
||||
|
||||
export const FILTERS: { id: HistoryFilter; label: string }[] = [
|
||||
{ id: "all", label: "All events" },
|
||||
{ id: "reversals", label: "Reversals" },
|
||||
{ id: "missed", label: "Missed" },
|
||||
];
|
||||
|
||||
export interface HistoryStats {
|
||||
/** Whole percent, or null when the period has no completions. */
|
||||
onTimePct: number | null;
|
||||
completed: number;
|
||||
missed: number;
|
||||
}
|
||||
|
||||
/** Per-period stats from the `all` rows (completions) and the `missed` rows. */
|
||||
export function computeStats(allRows: HistoryRow[], missedRows: HistoryRow[]): HistoryStats {
|
||||
const completions = allRows.filter((row) => row.event_type === "completed");
|
||||
const onTime = completions.filter((row) => row.was_on_time === true).length;
|
||||
return {
|
||||
onTimePct: completions.length > 0 ? Math.round((onTime / completions.length) * 100) : null,
|
||||
completed: completions.length,
|
||||
missed: missedRows.filter((row) => row.event_type === "missed").length,
|
||||
};
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
rows: HistoryRow[];
|
||||
stats: HistoryStats;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
/** `key` is the (filter, start, end) the rows were loaded for. */
|
||||
| { kind: "ready"; data: Loaded; key: string };
|
||||
|
||||
/**
|
||||
* The selected filter's rows plus the period stats. Stats always come from
|
||||
* the `all` and `missed` queries, whichever chip is selected, so switching
|
||||
* chips never changes the period's numbers.
|
||||
*/
|
||||
async function loadHistory(filter: HistoryFilter, start: string, end: string): Promise<Loaded> {
|
||||
const [selected, all, missed] = await Promise.all([
|
||||
fetchHistory(filter, start, end),
|
||||
filter === "all" ? null : fetchHistory("all", start, end),
|
||||
filter === "missed" ? null : fetchHistory("missed", start, end),
|
||||
]);
|
||||
return {
|
||||
rows: selected.rows,
|
||||
stats: computeStats((all ?? selected).rows, (missed ?? selected).rows),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<header className="history-header">
|
||||
<div>
|
||||
<h2 className="history-title">History</h2>
|
||||
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="history-csv"
|
||||
data-testid="history-csv"
|
||||
disabled={exporting}
|
||||
aria-busy={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
<DownloadGlyph />
|
||||
CSV
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ stats }: { stats: HistoryStats }) {
|
||||
const items = [
|
||||
{ key: "on-time", label: "On time", value: stats.onTimePct === null ? "—" : `${stats.onTimePct}%` },
|
||||
{ key: "completed", label: "Completed", value: String(stats.completed) },
|
||||
{ key: "missed", label: "Missed", value: String(stats.missed) },
|
||||
];
|
||||
return (
|
||||
<div className="history-stats">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="history-stat" data-testid={`stat-${item.key}`}>
|
||||
<div className={`history-stat-value history-stat-value--${item.key}`}>{item.value}</div>
|
||||
<div className="history-stat-label">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ row }: { row: HistoryRow }) {
|
||||
return (
|
||||
<li className="history-event" data-testid="history-row">
|
||||
<span
|
||||
className={`history-dot history-dot--${row.event_type}`}
|
||||
data-testid="history-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="history-event-text">
|
||||
<div className="history-event-title">
|
||||
{row.quest_title} · {row.child_name}
|
||||
</div>
|
||||
<div className="history-event-meta" data-testid="history-meta">
|
||||
{rowMeta(row)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="history-event-time">{rowTime(row)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterNote() {
|
||||
return (
|
||||
<div className="history-note" data-testid="history-footer">
|
||||
<span className="history-note-glyph">
|
||||
<LockGlyph />
|
||||
</span>
|
||||
<span>{FOOTER_NOTE}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
||||
const [filter, setFilter] = useState<HistoryFilter>("all");
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
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 key = `${filter}|${start}|${today}`;
|
||||
|
||||
// A quest transition is a new history row (or changes a derived missed row);
|
||||
// a day-complete adds none. The refetch keeps the active filter and range.
|
||||
useTransitions((event: TransitionEvent) => {
|
||||
if (event.type === "nestquest_child_day_complete") return;
|
||||
setAttempt((n) => n + 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;
|
||||
// Rows already loaded for this (filter, range) stay on screen while they
|
||||
// refresh; any other rows give way to the loading state.
|
||||
const isCurrent = (prev: LoadState) => prev.kind === "ready" && prev.key === key;
|
||||
setState((prev) => (isCurrent(prev) ? prev : { kind: "loading" }));
|
||||
loadHistory(filter, start, today)
|
||||
.then((data) => {
|
||||
if (!cancelled) setState({ kind: "ready", data, key });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "The history could not be loaded. Check your connection and try again.";
|
||||
// A failed refresh keeps the rows already on screen.
|
||||
setState((prev) => (isCurrent(prev) ? prev : { kind: "error", message }));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filter, start, today, key, attempt]);
|
||||
|
||||
// Never render one filter's rows under another's chip, even for the render
|
||||
// before the load effect runs.
|
||||
const shown: LoadState = state.kind === "ready" && state.key !== key ? { kind: "loading" } : state;
|
||||
|
||||
let body;
|
||||
if (shown.kind === "loading") {
|
||||
body = (
|
||||
<div className="history-status" role="status" data-testid="history-loading">
|
||||
Loading history…
|
||||
</div>
|
||||
);
|
||||
} else if (shown.kind === "error") {
|
||||
body = (
|
||||
<div className="history-status history-status--error" role="alert" data-testid="history-error">
|
||||
<p>{shown.message}</p>
|
||||
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const groups = groupByDay(shown.data.rows);
|
||||
body =
|
||||
groups.length > 0 ? (
|
||||
groups.map((group) => (
|
||||
<section key={group.day} className="history-day" data-testid={`day-${group.day}`}>
|
||||
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
|
||||
<ul className="history-card history-list">
|
||||
{group.rows.map((row, index) => (
|
||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="history-card history-empty">No events in the last {RANGE_DAYS} days.</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="history-screen">
|
||||
<ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
|
||||
<div
|
||||
className="history-scroll"
|
||||
data-testid="history-scroll"
|
||||
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}
|
||||
{shown.kind === "ready" ? <StatRow stats={shown.data.stats} /> : null}
|
||||
<div className="history-chips" role="group" aria-label="Filter events">
|
||||
{FILTERS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={item.id === filter ? "history-chip history-chip--active" : "history-chip"}
|
||||
aria-pressed={item.id === filter}
|
||||
onClick={() => setFilter(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{body}
|
||||
<FooterNote />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Day grouping and row wording for the History tab.
|
||||
*
|
||||
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones); event stamps are UTC and shown in the
|
||||
* viewer's local time.
|
||||
*/
|
||||
import type { HistoryRow } from "../api/history";
|
||||
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
|
||||
import { shortDate } from "../schedule/format";
|
||||
import { formatClockTime, formatDueTime } from "../today/format";
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
/** `iso` shifted by `days` calendar days. */
|
||||
export function shiftIso(iso: string, days: number): string {
|
||||
return isoFromDayNumber(dayNumber(iso) + days);
|
||||
}
|
||||
|
||||
/** The local calendar day an event belongs to (due_date for missed rows). */
|
||||
export function rowDay(row: HistoryRow): string {
|
||||
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
|
||||
}
|
||||
|
||||
/** The row's due moment as local wall-clock time, or null without a due time. */
|
||||
function dueMoment(row: HistoryRow): Date | null {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
|
||||
if (!match) return null;
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
|
||||
}
|
||||
|
||||
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
|
||||
function sortKey(row: HistoryRow): number {
|
||||
if (row.occurred_at) return Date.parse(row.occurred_at);
|
||||
const due = dueMoment(row);
|
||||
if (due) return due.getTime();
|
||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
||||
return new Date(year, month - 1, day, 23, 59).getTime();
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
day: string;
|
||||
rows: HistoryRow[];
|
||||
}
|
||||
|
||||
/** Rows grouped by day, newest day first and newest row first within a day. */
|
||||
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
||||
const groups = new Map<string, HistoryRow[]>();
|
||||
for (const row of rows) {
|
||||
const day = rowDay(row);
|
||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([day, dayRows]) => ({
|
||||
day,
|
||||
// Stable sort: rows at the same moment keep the API's append order.
|
||||
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** "Today", "Yesterday", else "Mon, Sep 21" (the CSS uppercases it). */
|
||||
export function dayLabel(day: string, today: string): string {
|
||||
if (day === today) return "Today";
|
||||
if (day === shiftIso(today, -1)) return "Yesterday";
|
||||
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(
|
||||
new Date(dayNumber(day) * 86_400_000),
|
||||
);
|
||||
return `${weekday}, ${shortDate(day)}`;
|
||||
}
|
||||
|
||||
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
|
||||
function onTimeFlag(row: HistoryRow): string | null {
|
||||
if (row.event_type !== "completed" || row.was_on_time === null) return null;
|
||||
if (row.was_on_time) return "on time";
|
||||
const due = dueMoment(row);
|
||||
if (!due || !row.occurred_at) return "late";
|
||||
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
|
||||
return minutes > 0 ? `late ${minutes} min` : "late";
|
||||
}
|
||||
|
||||
/** `<event type> · <actor>` plus the on-time flag for completions. */
|
||||
export function rowMeta(row: HistoryRow): string {
|
||||
const flag = onTimeFlag(row);
|
||||
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The event's local clock time; a missed row shows its due time, if any. */
|
||||
export function rowTime(row: HistoryRow): string {
|
||||
if (row.occurred_at) return formatClockTime(row.occurred_at);
|
||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the History tab (Lucide-style 24px grid),
|
||||
* CDN-free like the other tabs' glyphs.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadGlyph({ size = 15 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function LockGlyph({ size = 15 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user