Task 9b9188b8: History tab - filters, rows, stats and footer (#208)

This commit was merged in pull request #208.
This commit is contained in:
2026-09-23 23:26:03 -04:00
parent cc970bb646
commit bc3e64b4f8
10 changed files with 952 additions and 23 deletions
-11
View File
@@ -48,17 +48,6 @@
border: 0; border: 0;
} }
.admin-placeholder {
margin: 0;
padding: 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.admin-tabbar { .admin-tabbar {
position: fixed; position: fixed;
inset: auto 0 0; inset: auto 0 0;
+2 -3
View File
@@ -29,9 +29,8 @@ describe("NestQuest Admin shell", () => {
fireEvent.click(screen.getByRole("tab", { name: "History" })); fireEvent.click(screen.getByRole("tab", { name: "History" }));
expect(screen.getByRole("tabpanel").textContent).toContain( expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
"History placeholder", expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
);
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe( expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
"true", "true",
); );
+6 -9
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import "./App.css"; import "./App.css";
import DefinitionsTab from "./definitions/DefinitionsTab"; import DefinitionsTab from "./definitions/DefinitionsTab";
import HistoryTab from "./history/HistoryTab";
import ScheduleTab from "./schedule/ScheduleTab"; import ScheduleTab from "./schedule/ScheduleTab";
import TodayTab from "./today/TodayTab"; import TodayTab from "./today/TodayTab";
@@ -15,18 +16,17 @@ type TabId = (typeof TABS)[number]["id"];
export default function App() { export default function App() {
const [active, setActive] = useState<TabId>("today"); const [active, setActive] = useState<TabId>("today");
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
// stays for placeholder tabs and is visually hidden otherwise.
const hasScreen = active === "today" || active === "tasks" || active === "schedule";
return ( return (
<div className="admin-shell"> <div className="admin-shell">
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}> {/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
generic shell header stays only as a visually hidden page heading. */}
<header className="admin-header visually-hidden">
<h1 className="admin-title">NestQuest Admin</h1> <h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p> <p className="admin-subline">Admin console</p>
</header> </header>
<main <main
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"} className="admin-panel admin-panel--screen"
role="tabpanel" role="tabpanel"
aria-labelledby={`tab-${active}`} aria-labelledby={`tab-${active}`}
> >
@@ -37,10 +37,7 @@ export default function App() {
) : active === "schedule" ? ( ) : active === "schedule" ? (
<ScheduleTab /> <ScheduleTab />
) : ( ) : (
<p className="admin-placeholder"> <HistoryTab />
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
)} )}
</main> </main>
<nav className="admin-tabbar" aria-label="Admin sections"> <nav className="admin-tabbar" aria-label="Admin sections">
+48
View File
@@ -0,0 +1,48 @@
/**
* Typed client for the admin history route (/api/v1/admin/history): the
* append-only event log over a closed due-date range for one filter.
*/
import { apiFetch } from "./client";
import { readJson } from "./definitions";
export type HistoryFilter = "all" | "reversals" | "missed";
export type HistoryEventType = "completed" | "uncompleted" | "missed";
export interface HistoryRow {
/** UTC ISO stamp; null for a derived missed row (there is no event). */
occurred_at: string | null;
event_type: HistoryEventType;
child_name: string;
child_id: number;
quest_title: string;
instance_id: number;
window: string;
/** "YYYY-MM-DD", the day the task was owed. */
due_date: string;
/** Local wall-clock "HH:MM", or null. */
due_time: string | null;
/** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
actor: string;
was_on_time: boolean | null;
}
export interface HistoryResponse {
filter: HistoryFilter;
start: string;
end: string;
count: number;
rows: HistoryRow[];
}
export const HISTORY_PATH = "/api/v1/admin/history";
/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
export async function fetchHistory(
filter: HistoryFilter,
start: string,
end: string,
): Promise<HistoryResponse> {
const params = new URLSearchParams({ start, end, filter });
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
}
+265
View File
@@ -0,0 +1,265 @@
/* 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;
}
/* 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;
}
+268
View File
@@ -0,0 +1,268 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { 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";
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" },
});
}
function routeFetch(url: string): Response {
const parsed = new URL(url, "http://x");
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 and an inert CSV button", 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);
});
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");
});
});
+227
View File
@@ -0,0 +1,227 @@
/**
* 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, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import { 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 }
| { kind: "ready"; data: Loaded };
/**
* 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),
};
}
function ScreenHeader() {
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>
{/* TODO(e5133d0f): export the filtered range as CSV; inert until then. */}
<button type="button" className="history-csv" data-testid="history-csv">
<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 start = shiftIso(today, -(RANGE_DAYS - 1));
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
loadHistory(filter, start, today)
.then((data) => {
if (!cancelled) setState({ kind: "ready", data });
})
.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.";
setState({ kind: "error", message });
});
return () => {
cancelled = true;
};
}, [filter, start, today, attempt]);
let body;
if (state.kind === "loading") {
body = (
<div className="history-status" role="status" data-testid="history-loading">
Loading history…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="history-status history-status--error" role="alert" data-testid="history-error">
<p>{state.message}</p>
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const groups = groupByDay(state.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 />
<div
className="history-scroll"
data-testid="history-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{state.kind === "ready" ? <StatRow stats={state.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>
);
}
+94
View File
@@ -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) : "";
}
+41
View File
@@ -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>
);
}
+1
View File
@@ -30,6 +30,7 @@
--nq-a-info-bg: #dbeafe; --nq-a-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a; --nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8; --nq-a-info-icon: #1d4ed8;
--nq-a-reversal: #7d28a0;
--nq-a-font: Nunito, system-ui, sans-serif; --nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px; --nq-a-size-screen: 24px;