From 26f7d3a7d10e681ab38e37c63cd924e97bd32c36 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 23:40:10 -0400 Subject: [PATCH] Task e57facc2: Undo and live SSE updates in the admin PWA - api/events.ts: fetch-based reader for GET /api/v1/admin/events (admin JWT in the Authorization header via apiFetch, never in the URL), an incremental SSE frame parser that buffers frames split across chunks and drops malformed frames, and bounded-backoff reconnect with a clean abort on stop. - api/useTransitions.tsx: one stream per signed-in shell (TransitionsProvider in App) fanned out to screens; no provider means no stream. - Today: Undo posts /instances/{id}/uncomplete, disables every Undo while in flight, shows a dismissible message on failure, and refetches the snapshot; any transition refetches in place. Mark done stays inert. - History: quest transitions refetch the active filter in place; rows stay read-only. - Tests for parsing, stream auth/teardown/reconnect, Undo, and live refetch. Co-Authored-By: Claude Opus 5.5 --- admin/src/App.tsx | 79 ++++++----- admin/src/api/events.test.ts | 162 +++++++++++++++++++++ admin/src/api/events.ts | 196 ++++++++++++++++++++++++++ admin/src/api/instances.ts | 23 +++ admin/src/api/testStream.ts | 39 +++++ admin/src/api/useTransitions.tsx | 61 ++++++++ admin/src/history/HistoryTab.test.tsx | 103 ++++++++++++++ admin/src/history/HistoryTab.tsx | 19 ++- admin/src/today/TodayTab.css | 31 ++++ admin/src/today/TodayTab.test.tsx | 187 +++++++++++++++++++++++- admin/src/today/TodayTab.tsx | 96 +++++++++++-- 11 files changed, 942 insertions(+), 54 deletions(-) create mode 100644 admin/src/api/events.test.ts create mode 100644 admin/src/api/events.ts create mode 100644 admin/src/api/instances.ts create mode 100644 admin/src/api/testStream.ts create mode 100644 admin/src/api/useTransitions.tsx diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 3e36c31..5f44fad 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import "./App.css"; +import { TransitionsProvider } from "./api/useTransitions"; import DefinitionsTab from "./definitions/DefinitionsTab"; import HistoryTab from "./history/HistoryTab"; import ScheduleTab from "./schedule/ScheduleTab"; @@ -18,43 +19,45 @@ export default function App() { const [active, setActive] = useState("today"); return ( -
- {/* 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. */} -
-

NestQuest Admin

-

Admin console

-
-
- {active === "today" ? ( - - ) : active === "tasks" ? ( - - ) : active === "schedule" ? ( - - ) : ( - - )} -
- -
+ +
+ {/* 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. */} +
+

NestQuest Admin

+

Admin console

+
+
+ {active === "today" ? ( + + ) : active === "tasks" ? ( + + ) : active === "schedule" ? ( + + ) : ( + + )} +
+ +
+
); } \ No newline at end of file diff --git a/admin/src/api/events.test.ts b/admin/src/api/events.test.ts new file mode 100644 index 0000000..33a24fd --- /dev/null +++ b/admin/src/api/events.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events"; +import { frame, testStream, type TestStream } from "./testStream"; +import { storeTokens } from "../auth/oidc"; + +const COMPLETED = { + child_id: 1, + child_name: "Declan", + instance_id: 12, + quest_title: "Make bed", + window: "morning", + due_date: "2026-09-23", + due_time: null, + occurred_at: "2026-09-23T11:04:00Z", +}; +const DAY_COMPLETE = { + child_id: 2, + child_name: "Maeve", + quests_due: 4, + quests_completed: 4, + occurred_at: "2026-09-23T12:00:00Z", +}; + +describe("SseParser", () => { + it("yields every frame from one chunk", () => { + const parser = new SseParser(); + const events = parser.push( + frame("nestquest_quest_completed", COMPLETED) + + frame("nestquest_child_day_complete", DAY_COMPLETE), + ); + expect(events).toEqual([ + { type: "nestquest_quest_completed", payload: COMPLETED }, + { type: "nestquest_child_day_complete", payload: DAY_COMPLETE }, + ]); + }); + + it("buffers a frame split across two chunks", () => { + const parser = new SseParser(); + const text = frame("nestquest_quest_uncompleted", COMPLETED); + const cut = text.indexOf("Make"); + expect(parser.push(text.slice(0, cut))).toEqual([]); + expect(parser.push(text.slice(cut))).toEqual([ + { type: "nestquest_quest_uncompleted", payload: COMPLETED }, + ]); + }); + + it("buffers a split inside the event name and the blank-line terminator", () => { + const parser = new SseParser(); + const text = frame("nestquest_quest_missed", COMPLETED); + expect(parser.push(text.slice(0, 12))).toEqual([]); + expect(parser.push(text.slice(12, -1))).toEqual([]); + expect(parser.push(text.slice(-1))).toEqual([ + { type: "nestquest_quest_missed", payload: COMPLETED }, + ]); + }); + + it("treats a CRLF split across chunks as one line break", () => { + const parser = new SseParser(); + const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`; + const cut = text.indexOf("\n"); // chunk ends on "\r" + expect(parser.push(text.slice(0, cut))).toEqual([]); + expect(parser.push(text.slice(cut))).toEqual([ + { type: "nestquest_quest_completed", payload: COMPLETED }, + ]); + }); + + it("drops malformed, unknown and comment frames without throwing", () => { + const parser = new SseParser(); + const events = parser.push( + "event: nestquest_quest_completed\ndata: not-json\n\n" + + "event: nestquest_quest_completed\ndata: [1,2]\n\n" + + "event: something_else\ndata: {}\n\n" + + ": keep-alive\n\n" + + "data: {}\n\n" + + frame("nestquest_quest_completed", COMPLETED), + ); + expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]); + }); +}); + +describe("openTransitionStream", () => { + let fetchMock: ReturnType; + let streams: TestStream[]; + let stop: (() => void) | null; + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + streams = []; + stop = null; + fetchMock = vi.fn(() => { + const stream = testStream(); + streams.push(stream); + return Promise.resolve(stream.response); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + stop?.(); + vi.unstubAllGlobals(); + }); + + it("sends the token in the Authorization header, never in the URL", async () => { + stop = openTransitionStream({ onTransition: () => {} }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`)); + expect(String(url)).not.toContain("at-123"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + expect(init.headers.Accept).toBe("text/event-stream"); + }); + + it("delivers transitions from frames split across chunks", async () => { + const seen: TransitionEvent[] = []; + stop = openTransitionStream({ onTransition: (event) => seen.push(event) }); + await waitFor(() => expect(streams).toHaveLength(1)); + const text = frame("nestquest_quest_completed", COMPLETED); + streams[0].push(text.slice(0, 20)); + streams[0].push(text.slice(20)); + await waitFor(() => + expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]), + ); + }); + + it("stop aborts the request and cancels the body", async () => { + stop = openTransitionStream({ onTransition: () => {} }); + await waitFor(() => expect(streams).toHaveLength(1)); + const signal = fetchMock.mock.calls[0][1].signal as AbortSignal; + stop(); + expect(signal.aborted).toBe(true); + await waitFor(() => expect(streams[0].cancelled()).toBe(true)); + }); + + it("reconnects after the stream drops", async () => { + const seen: TransitionEvent[] = []; + stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 }); + await waitFor(() => expect(streams).toHaveLength(1)); + streams[0].close(); + await waitFor(() => expect(streams).toHaveLength(2)); + streams[1].push(frame("nestquest_quest_missed", COMPLETED)); + await waitFor(() => expect(seen).toHaveLength(1)); + }); + + it("does not reconnect after stop, even with a reconnect pending", async () => { + stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 }); + await waitFor(() => expect(streams).toHaveLength(1)); + streams[0].close(); + stop(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("gives up on a 403 refusal instead of retrying", async () => { + fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 }))); + stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/admin/src/api/events.ts b/admin/src/api/events.ts new file mode 100644 index 0000000..bf6b572 --- /dev/null +++ b/admin/src/api/events.ts @@ -0,0 +1,196 @@ +/** + * Live transition stream: GET /api/v1/admin/events (text/event-stream). + * + * The admin JWT travels in the Authorization header (via apiFetch), so the + * stream is read with fetch + a ReadableStream reader — EventSource cannot + * set headers, and the token must never go into a URL. + */ +import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client"; + +export const EVENTS_PATH = "/api/v1/admin/events"; + +/** The four documented transitions, as the API names them on the wire. */ +export const TRANSITION_TYPES = [ + "nestquest_quest_completed", + "nestquest_quest_uncompleted", + "nestquest_quest_missed", + "nestquest_child_day_complete", +] as const; + +export type TransitionType = (typeof TRANSITION_TYPES)[number]; + +/** Quest transitions carry the instance; a day-complete carries the child's counts. */ +export interface QuestTransitionPayload { + child_id: number; + child_name: string | null; + instance_id: number; + quest_title: string | null; + window: string; + due_date: string; + due_time: string | null; + occurred_at: string; +} + +export interface ChildDayCompletePayload { + child_id: number; + child_name: string | null; + quests_due: number; + quests_completed: number; + occurred_at: string; +} + +export type TransitionEvent = + | { + type: + | "nestquest_quest_completed" + | "nestquest_quest_uncompleted" + | "nestquest_quest_missed"; + payload: QuestTransitionPayload; + } + | { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload }; + +function isTransitionType(value: string): value is TransitionType { + return (TRANSITION_TYPES as readonly string[]).includes(value); +} + +/** + * Incremental SSE frame parser. Feed it decoded text in whatever chunks the + * network delivers; it buffers a partial line or frame until it completes. + * Comments (keep-alives), unknown event types and frames whose data is not a + * JSON object are dropped, never thrown. + */ +export class SseParser { + private buffer = ""; + private eventType = ""; + private data: string[] = []; + /** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */ + private pendingCr = false; + + push(text: string): TransitionEvent[] { + if (this.pendingCr && text.startsWith("\n")) text = text.slice(1); + this.pendingCr = text.endsWith("\r"); + this.buffer += text; + const lines = this.buffer.split(/\r\n|\r|\n/); + this.buffer = lines.pop() ?? ""; + const events: TransitionEvent[] = []; + for (const line of lines) { + const event = this.line(line); + if (event) events.push(event); + } + return events; + } + + private line(line: string): TransitionEvent | null { + if (line === "") return this.dispatch(); + if (line.startsWith(":")) return null; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) value = value.slice(1); + if (field === "event") this.eventType = value; + else if (field === "data") this.data.push(value); + return null; + } + + private dispatch(): TransitionEvent | null { + const type = this.eventType; + const data = this.data.join("\n"); + this.eventType = ""; + this.data = []; + if (!isTransitionType(type)) return null; + let payload: unknown; + try { + payload = JSON.parse(data); + } catch { + return null; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + return { type, payload } as TransitionEvent; + } +} + +export interface TransitionStreamOptions { + onTransition: (event: TransitionEvent) => void; + /** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */ + minDelayMs?: number; + maxDelayMs?: number; +} + +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + function done() { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + } + }); +} + +/** Read one connection until the body ends (or the signal aborts). */ +async function readOnce( + signal: AbortSignal, + onTransition: (event: TransitionEvent) => void, +): Promise { + const response = await apiFetch(EVENTS_PATH, { + signal, + headers: { Accept: "text/event-stream" }, + }); + if (!response.ok || !response.body) { + throw new Error(`Event stream request failed (HTTP ${response.status}).`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const parser = new SseParser(); + const cancel = () => void reader.cancel().catch(() => {}); + signal.addEventListener("abort", cancel, { once: true }); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done || signal.aborted) break; + for (const event of parser.push(decoder.decode(value, { stream: true }))) { + if (signal.aborted) break; + onTransition(event); + } + } + } finally { + signal.removeEventListener("abort", cancel); + reader.releaseLock(); + } +} + +/** + * Subscribe to the admin transition stream, reconnecting with a bounded + * backoff after a drop. Returns a stop function that aborts the open request + * and any pending reconnect. A 401/403 stops the stream for good: apiFetch + * has already started a login (401) or the account is refused (403). + */ +export function openTransitionStream({ + onTransition, + minDelayMs = 1_000, + maxDelayMs = 30_000, +}: TransitionStreamOptions): () => void { + const controller = new AbortController(); + const { signal } = controller; + + void (async () => { + let delay = minDelayMs; + while (!signal.aborted) { + try { + await readOnce(signal, onTransition); + // A connection that was accepted and then ended starts the backoff over. + delay = minDelayMs; + } catch (error: unknown) { + if (signal.aborted) return; + if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return; + // Dropped or refused connection: fall through to the backoff. + } + if (signal.aborted) return; + await wait(delay, signal); + delay = Math.min(delay * 2, maxDelayMs); + } + })(); + + return () => controller.abort(); +} diff --git a/admin/src/api/instances.ts b/admin/src/api/instances.ts new file mode 100644 index 0000000..bcc3c33 --- /dev/null +++ b/admin/src/api/instances.ts @@ -0,0 +1,23 @@ +/** + * Typed client for the admin instance mutation route + * (POST /api/v1/admin/instances/{id}/uncomplete). + */ +import { apiFetch } from "./client"; +import { readJson } from "./definitions"; + +export interface UncompleteResult { + instance_id: number; + /** The derived state after the reversal: "open", or "missed" when past due. */ + state: string; + /** False when the instance was already open (a no-op). */ + appended: boolean; +} + +export function uncompletePath(instanceId: number): string { + return `/api/v1/admin/instances/${instanceId}/uncomplete`; +} + +/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */ +export async function uncompleteInstance(instanceId: number): Promise { + return readJson(await apiFetch(uncompletePath(instanceId), { method: "POST" })); +} diff --git a/admin/src/api/testStream.ts b/admin/src/api/testStream.ts new file mode 100644 index 0000000..18c469e --- /dev/null +++ b/admin/src/api/testStream.ts @@ -0,0 +1,39 @@ +/** + * Test helper: a controllable text/event-stream Response whose body the test + * feeds chunk by chunk (so a frame can be split across chunk boundaries). + */ +export interface TestStream { + response: Response; + push(text: string): void; + close(): void; + /** True once the reader cancelled the body (the stream was torn down). */ + cancelled(): boolean; +} + +export function testStream(): TestStream { + const encoder = new TextEncoder(); + let controller!: ReadableStreamDefaultController; + let cancelled = false; + const body = new ReadableStream({ + start(c) { + controller = c; + }, + cancel() { + cancelled = true; + }, + }); + return { + response: new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + push: (text) => controller.enqueue(encoder.encode(text)), + close: () => controller.close(), + cancelled: () => cancelled, + }; +} + +/** One SSE frame exactly as api/sse.py writes it. */ +export function frame(type: string, payload: unknown): string { + return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`; +} diff --git a/admin/src/api/useTransitions.tsx b/admin/src/api/useTransitions.tsx new file mode 100644 index 0000000..e021d79 --- /dev/null +++ b/admin/src/api/useTransitions.tsx @@ -0,0 +1,61 @@ +/** + * One live transition stream per signed-in shell, fanned out to the screens. + * + * opens the stream on mount and aborts it on unmount; + * useTransitions(handler) registers a screen's handler for as long as the + * screen is mounted. Without a provider the hook does nothing, so a screen + * rendered on its own (e.g. in a test) never opens a stream. + */ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { openTransitionStream, type TransitionEvent } from "./events"; + +type Listener = (event: TransitionEvent) => void; + +interface TransitionHub { + subscribe(listener: Listener): () => void; +} + +const TransitionsContext = createContext(null); + +export function TransitionsProvider({ children }: { children: ReactNode }) { + const [hub] = useState(() => { + const listeners = new Set(); + return { + listeners, + subscribe(listener: Listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + }); + + useEffect( + () => + openTransitionStream({ + onTransition: (event) => { + for (const listener of [...hub.listeners]) { + try { + listener(event); + } catch { + // One screen's handler must not tear down the shared stream. + } + } + }, + }), + [hub], + ); + + return {children}; +} + +/** Call `handler` for every transition while the calling component is mounted. */ +export function useTransitions(handler: Listener): void { + const hub = useContext(TransitionsContext); + const handlerRef = useRef(handler); + useEffect(() => { + handlerRef.current = handler; + }); + useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]); +} diff --git a/admin/src/history/HistoryTab.test.tsx b/admin/src/history/HistoryTab.test.tsx index a828e09..99bf331 100644 --- a/admin/src/history/HistoryTab.test.tsx +++ b/admin/src/history/HistoryTab.test.tsx @@ -3,6 +3,8 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li 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"; @@ -423,3 +425,104 @@ describe("HistoryTab", () => { expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px"); }); }); + +describe("HistoryTab live updates", () => { + let fetchMock: ReturnType; + 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( + + + , + ); + 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(); + } + }); +}); diff --git a/admin/src/history/HistoryTab.tsx b/admin/src/history/HistoryTab.tsx index f929166..a69a093 100644 --- a/admin/src/history/HistoryTab.tsx +++ b/admin/src/history/HistoryTab.tsx @@ -6,6 +6,8 @@ */ 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"; @@ -165,8 +167,18 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); const exportingRef = useRef(false); + /** The next load is a refresh in place: keep the current rows, no loading state. */ + const silentNext = useRef(false); const start = shiftIso(today, -(RANGE_DAYS - 1)); + // 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; + silentNext.current = true; + setAttempt((n) => n + 1); + }); + /** Download the CSV for the active filter over the displayed range. */ async function exportCsv() { if (exportingRef.current) return; @@ -190,7 +202,9 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string useEffect(() => { let cancelled = false; - setState({ kind: "loading" }); + const silent = silentNext.current; + silentNext.current = false; + if (!silent) setState({ kind: "loading" }); loadHistory(filter, start, today) .then((data) => { if (!cancelled) setState({ kind: "ready", data }); @@ -201,7 +215,8 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string error instanceof ApiForbiddenError ? error.message : "The history could not be loaded. Check your connection and try again."; - setState({ kind: "error", message }); + // A failed refresh keeps the rows already on screen. + setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message })); }); return () => { cancelled = true; diff --git a/admin/src/today/TodayTab.css b/admin/src/today/TodayTab.css index 5c202ec..760ac92 100644 --- a/admin/src/today/TodayTab.css +++ b/admin/src/today/TodayTab.css @@ -241,6 +241,37 @@ color: var(--nq-brand-purple); } +.today-action:disabled { + cursor: default; + opacity: 0.5; +} + +/* Non-blocking Undo failure message. */ +.today-action-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; +} + +.today-action-dismiss { + flex: none; + padding: 4px 8px; + border: none; + background: none; + color: inherit; + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + .today-empty { padding: var(--nq-a-density-card-pad); font-size: 13px; diff --git a/admin/src/today/TodayTab.test.tsx b/admin/src/today/TodayTab.test.tsx index a8b7040..41a59b7 100644 --- a/admin/src/today/TodayTab.test.tsx +++ b/admin/src/today/TodayTab.test.tsx @@ -1,7 +1,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import TodayTab, { PERMISSION_NOTE } from "./TodayTab"; import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot"; +import { TransitionsProvider } from "../api/useTransitions"; +import { frame, testStream, type TestStream } from "../api/testStream"; import { storeTokens } from "../auth/oidc"; function instance(overrides: Partial): SnapshotInstance { @@ -221,3 +223,186 @@ describe("TodayTab", () => { expect(error.textContent).toContain("nestquest-admins"); }); }); + +/** SNAPSHOT after instance 12 ("Make bed") is reversed back to open. */ +const AFTER_UNDO: AdminSnapshot = { + ...SNAPSHOT, + children: SNAPSHOT.children.map((child) => + child.child_id !== 1 + ? child + : { + ...child, + completed_today: 2, + remaining_today: 5, + completion_pct: 29, + instances: child.instances.map((i) => + i.id === 12 ? { ...i, state: "open" as const, completed_at: null, on_time: null } : i, + ), + }, + ), +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("TodayTab Undo and live updates", () => { + let fetchMock: ReturnType; + let snapshots: AdminSnapshot[]; + let uncomplete: () => Promise; + let streams: TestStream[]; + + function calls(pathSuffix: string, method = "GET") { + return fetchMock.mock.calls.filter( + ([url, init]) => + new URL(String(url), "http://x").pathname.endsWith(pathSuffix) && + (init?.method ?? "GET") === method, + ); + } + + beforeAll(() => { + vi.stubEnv("TZ", "America/Los_Angeles"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + snapshots = [SNAPSHOT, AFTER_UNDO]; + uncomplete = () => + Promise.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true })); + streams = []; + fetchMock = vi.fn((url: string) => { + const path = new URL(url, "http://x").pathname; + if (path.endsWith("/api/v1/admin/snapshot")) { + const next = snapshots.length > 1 ? snapshots.shift()! : snapshots[0]; + return Promise.resolve(jsonResponse(next)); + } + if (path.endsWith("/api/v1/admin/instances/12/uncomplete")) return uncomplete(); + if (path.endsWith("/api/v1/admin/events")) { + const stream = testStream(); + streams.push(stream); + return Promise.resolve(stream.response); + } + return Promise.resolve(jsonResponse({ detail: "unexpected" }, 500)); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("Undo POSTs uncomplete for the completed instance and refetches the snapshot", async () => { + render(); + const row = await screen.findByTestId("attention-12"); + fireEvent.click(within(row).getByRole("button", { name: "Undo" })); + + await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull()); + const posts = calls("/api/v1/admin/instances/12/uncomplete", "POST"); + expect(posts).toHaveLength(1); + expect(posts[0][1].headers.Authorization).toBe("Bearer at-123"); + expect(calls("/api/v1/admin/snapshot")).toHaveLength(2); + // The reversed instance is open again: one fewer done, one more remaining. + expect(screen.getByTestId("stat-done").textContent).toBe("6Done"); + expect(screen.getByTestId("stat-remaining").textContent).toBe("5Remaining"); + expect(screen.queryByTestId("today-undo-error")).toBeNull(); + }); + + it("disables Undo while the request is in flight (no double submit)", async () => { + const pending = deferred(); + uncomplete = () => pending.promise; + render(); + const row = await screen.findByTestId("attention-12"); + const button = within(row).getByRole("button", { name: "Undo" }) as HTMLButtonElement; + + fireEvent.click(button); + await waitFor(() => expect(button.disabled).toBe(true)); + expect(button.textContent).toBe("Undoing…"); + // Every Undo is disabled while one reversal is in flight. + const other = within(screen.getByTestId("attention-14")).getByRole("button", { + name: "Undo", + }) as HTMLButtonElement; + expect(other.disabled).toBe(true); + fireEvent.click(button); + fireEvent.click(other); + expect(calls("/api/v1/admin/instances/12/uncomplete", "POST")).toHaveLength(1); + expect(calls("/uncomplete", "POST")).toHaveLength(1); + + pending.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true })); + await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull()); + expect(other.disabled).toBe(false); + }); + + it("a failed Undo shows a message and leaves the view intact", async () => { + uncomplete = () => Promise.resolve(jsonResponse({ detail: "instance 12 not found" }, 404)); + render(); + const row = await screen.findByTestId("attention-12"); + fireEvent.click(within(row).getByRole("button", { name: "Undo" })); + + const alert = await screen.findByTestId("today-undo-error"); + expect(alert.textContent).toContain("The completion could not be undone: instance 12 not found"); + // Still the original snapshot: nothing refetched, the row and its Undo remain usable. + expect(calls("/api/v1/admin/snapshot")).toHaveLength(1); + const button = within(screen.getByTestId("attention-12")).getByRole("button", { + name: "Undo", + }) as HTMLButtonElement; + expect(button.disabled).toBe(false); + expect(screen.getByTestId("stat-done").textContent).toBe("7Done"); + + fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" })); + expect(screen.queryByTestId("today-undo-error")).toBeNull(); + }); + + it("Mark done stays inert", async () => { + render(); + const row = await screen.findByTestId("attention-11"); + fireEvent.click(within(row).getByRole("button", { name: "Mark done" })); + expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0); + }); + + it("a transition on the live stream refetches the snapshot without a reload", async () => { + const { unmount } = render( + + + , + ); + await screen.findByTestId("attention-12"); + await waitFor(() => expect(streams).toHaveLength(1)); + const [, init] = calls("/api/v1/admin/events")[0]; + expect(init.headers.Authorization).toBe("Bearer at-123"); + + // An uncompletion made elsewhere, its frame split across two chunks. + const text = frame("nestquest_quest_uncompleted", { + child_id: 1, + child_name: "Declan", + instance_id: 12, + quest_title: "Make bed", + window: "morning", + due_date: "2026-09-23", + due_time: null, + occurred_at: "2026-09-23T12:00:00Z", + }); + streams[0].push(text.slice(0, 25)); + streams[0].push(text.slice(25)); + + await waitFor(() => expect(calls("/api/v1/admin/snapshot")).toHaveLength(2)); + await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull()); + // Refreshed in place: the loading state never replaced the view. + expect(screen.queryByTestId("today-loading")).toBeNull(); + + const signal = init.signal as AbortSignal; + expect(signal.aborted).toBe(false); + unmount(); + expect(signal.aborted).toBe(true); + await waitFor(() => expect(streams[0].cancelled()).toBe(true)); + }); +}); diff --git a/admin/src/today/TodayTab.tsx b/admin/src/today/TodayTab.tsx index 64e2d5e..9d4fedc 100644 --- a/admin/src/today/TodayTab.tsx +++ b/admin/src/today/TodayTab.tsx @@ -1,7 +1,7 @@ /** * Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6). */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { fetchSnapshot, type AdminSnapshot, @@ -9,6 +9,9 @@ import { type SnapshotInstance, } from "../api/snapshot"; import { ApiForbiddenError } from "../api/client"; +import { ApiRequestError } from "../api/definitions"; +import { uncompleteInstance } from "../api/instances"; +import { useTransitions } from "../api/useTransitions"; import { formatClockTime, formatDueTime, @@ -144,11 +147,18 @@ function attentionItems(snapshot: AdminSnapshot): AttentionItem[] { return [...overdue, ...completed]; } -function AttentionRow({ item }: { item: AttentionItem }) { +interface UndoControl { + /** The instance whose Undo is in flight, or null. */ + pendingId: number | null; + onUndo: (instanceId: number) => void; +} + +function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) { const { instance, childName } = item; const overdue = isOverdueOpen(instance); - // TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates. - // The admin plane has no complete route yet, so both actions are inert here. + const undoing = undo.pendingId === instance.id; + // TODO(e57facc2): wire "Mark done". The admin plane has no complete route + // yet, so that action is inert here. return (
  • )} - + {overdue ? ( + + ) : ( + + )}
  • ); } @@ -197,10 +216,25 @@ function PermissionNote() { export default function TodayTab() { const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); + /** The next load is a refresh in place: keep the current view, no loading state. */ + const silentNext = useRef(false); + const [undoingId, setUndoingId] = useState(null); + const undoingRef = useRef(false); + const [undoError, setUndoError] = useState(null); + + function refresh() { + silentNext.current = true; + setAttempt((n) => n + 1); + } + + // Any transition (completed, uncompleted, missed, day complete) changes the snapshot. + useTransitions(refresh); useEffect(() => { let cancelled = false; - setState({ kind: "loading" }); + const silent = silentNext.current; + silentNext.current = false; + if (!silent) setState({ kind: "loading" }); fetchSnapshot() .then((snapshot) => { if (!cancelled) setState({ kind: "ready", snapshot }); @@ -211,13 +245,37 @@ export default function TodayTab() { error instanceof ApiForbiddenError ? error.message : "Today's snapshot could not be loaded. Check your connection and try again."; - setState({ kind: "error", message }); + // A failed refresh keeps the snapshot already on screen. + setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message })); }); return () => { cancelled = true; }; }, [attempt]); + /** Reverse one completion, then refetch the snapshot. One request at a time. */ + async function undo(instanceId: number) { + if (undoingRef.current) return; + undoingRef.current = true; + setUndoingId(instanceId); + setUndoError(null); + try { + await uncompleteInstance(instanceId); + refresh(); + } catch (error: unknown) { + setUndoError( + error instanceof ApiForbiddenError + ? error.message + : error instanceof ApiRequestError && error.detail + ? `The completion could not be undone: ${error.detail}` + : "The completion could not be undone. Check your connection and try again.", + ); + } finally { + undoingRef.current = false; + setUndoingId(null); + } + } + let sub = ""; let body; if (state.kind === "loading") { @@ -251,7 +309,11 @@ export default function TodayTab() { {attention.length > 0 ? (
      {attention.map((item) => ( - + void undo(id) }} + /> ))}
    ) : ( @@ -270,6 +332,14 @@ export default function TodayTab() { data-testid="today-scroll" style={{ paddingBottom: SCROLL_BOTTOM_PADDING }} > + {undoError !== null ? ( +
    + {undoError} + +
    + ) : null} {body}