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..506bd08 100644 --- a/admin/src/history/HistoryTab.test.tsx +++ b/admin/src/history/HistoryTab.test.tsx @@ -1,8 +1,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +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"; @@ -423,3 +425,207 @@ 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(); + } + }); + + 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( + + + , + ); + 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((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( + + + , + ); + 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); + }); +}); diff --git a/admin/src/history/HistoryTab.tsx b/admin/src/history/HistoryTab.tsx index f929166..33ab53d 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"; @@ -51,7 +53,8 @@ interface Loaded { type LoadState = | { kind: "loading" } | { kind: "error"; message: string } - | { kind: "ready"; data: Loaded }; + /** `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 @@ -166,6 +169,14 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string const [exportError, setExportError] = useState(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() { @@ -190,10 +201,13 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string useEffect(() => { let cancelled = false; - setState({ kind: "loading" }); + // 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 }); + if (!cancelled) setState({ kind: "ready", data, key }); }) .catch((error: unknown) => { if (cancelled) return; @@ -201,31 +215,36 @@ 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) => (isCurrent(prev) ? prev : { kind: "error", message })); }); return () => { cancelled = true; }; - }, [filter, start, today, attempt]); + }, [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 (state.kind === "loading") { + if (shown.kind === "loading") { body = (
Loading history…
); - } else if (state.kind === "error") { + } else if (shown.kind === "error") { body = (
-

{state.message}

+

{shown.message}

); } else { - const groups = groupByDay(state.data.rows); + const groups = groupByDay(shown.data.rows); body = groups.length > 0 ? ( groups.map((group) => ( @@ -259,7 +278,7 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string ) : null} - {state.kind === "ready" ? : null} + {shown.kind === "ready" ? : null}
{FILTERS.map((item) => (
- + {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}