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 <noreply@anthropic.com>
This commit is contained in:
+41
-38
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import { TransitionsProvider } from "./api/useTransitions";
|
||||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||||
import HistoryTab from "./history/HistoryTab";
|
import HistoryTab from "./history/HistoryTab";
|
||||||
import ScheduleTab from "./schedule/ScheduleTab";
|
import ScheduleTab from "./schedule/ScheduleTab";
|
||||||
@@ -18,43 +19,45 @@ export default function App() {
|
|||||||
const [active, setActive] = useState<TabId>("today");
|
const [active, setActive] = useState<TabId>("today");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<TransitionsProvider>
|
||||||
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
|
<div className="admin-shell">
|
||||||
generic shell header stays only as a visually hidden page heading. */}
|
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
|
||||||
<header className="admin-header visually-hidden">
|
generic shell header stays only as a visually hidden page heading. */}
|
||||||
<h1 className="admin-title">NestQuest Admin</h1>
|
<header className="admin-header visually-hidden">
|
||||||
<p className="admin-subline">Admin console</p>
|
<h1 className="admin-title">NestQuest Admin</h1>
|
||||||
</header>
|
<p className="admin-subline">Admin console</p>
|
||||||
<main
|
</header>
|
||||||
className="admin-panel admin-panel--screen"
|
<main
|
||||||
role="tabpanel"
|
className="admin-panel admin-panel--screen"
|
||||||
aria-labelledby={`tab-${active}`}
|
role="tabpanel"
|
||||||
>
|
aria-labelledby={`tab-${active}`}
|
||||||
{active === "today" ? (
|
>
|
||||||
<TodayTab />
|
{active === "today" ? (
|
||||||
) : active === "tasks" ? (
|
<TodayTab />
|
||||||
<DefinitionsTab />
|
) : active === "tasks" ? (
|
||||||
) : active === "schedule" ? (
|
<DefinitionsTab />
|
||||||
<ScheduleTab />
|
) : active === "schedule" ? (
|
||||||
) : (
|
<ScheduleTab />
|
||||||
<HistoryTab />
|
) : (
|
||||||
)}
|
<HistoryTab />
|
||||||
</main>
|
)}
|
||||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
</main>
|
||||||
{TABS.map((tab) => (
|
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||||
<button
|
{TABS.map((tab) => (
|
||||||
key={tab.id}
|
<button
|
||||||
id={`tab-${tab.id}`}
|
key={tab.id}
|
||||||
type="button"
|
id={`tab-${tab.id}`}
|
||||||
role="tab"
|
type="button"
|
||||||
aria-selected={tab.id === active}
|
role="tab"
|
||||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
aria-selected={tab.id === active}
|
||||||
onClick={() => setActive(tab.id)}
|
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||||
>
|
onClick={() => setActive(tab.id)}
|
||||||
{tab.label}
|
>
|
||||||
</button>
|
{tab.label}
|
||||||
))}
|
</button>
|
||||||
</nav>
|
))}
|
||||||
</div>
|
</nav>
|
||||||
|
</div>
|
||||||
|
</TransitionsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<typeof vi.fn>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<UncompleteResult> {
|
||||||
|
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
|
||||||
|
}
|
||||||
@@ -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<Uint8Array>;
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* One live transition stream per signed-in shell, fanned out to the screens.
|
||||||
|
*
|
||||||
|
* <TransitionsProvider> 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<TransitionHub | null>(null);
|
||||||
|
|
||||||
|
export function TransitionsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [hub] = useState(() => {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
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 <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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]);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
|||||||
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
|
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
|
||||||
import type { HistoryFilter, HistoryRow } from "../api/history";
|
import type { HistoryFilter, HistoryRow } from "../api/history";
|
||||||
import { storeTokens } from "../auth/oidc";
|
import { storeTokens } from "../auth/oidc";
|
||||||
|
import { TransitionsProvider } from "../api/useTransitions";
|
||||||
|
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||||
|
|
||||||
const TODAY = "2026-09-23";
|
const TODAY = "2026-09-23";
|
||||||
|
|
||||||
@@ -423,3 +425,104 @@ describe("HistoryTab", () => {
|
|||||||
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
|
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HistoryTab live updates", () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
let streams: TestStream[];
|
||||||
|
|
||||||
|
function historyRequests(): URL[] {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.map(([url]) => new URL(String(url), "http://x"))
|
||||||
|
.filter((url) => url.pathname === "/api/v1/admin/history");
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD = {
|
||||||
|
child_id: 2,
|
||||||
|
child_name: "Maeve",
|
||||||
|
instance_id: 2,
|
||||||
|
quest_title: "Make bed",
|
||||||
|
window: "morning",
|
||||||
|
due_date: TODAY,
|
||||||
|
due_time: null,
|
||||||
|
occurred_at: "2026-09-23T15:10:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
streams = [];
|
||||||
|
fetchMock = vi.fn((url: string) => {
|
||||||
|
if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
|
||||||
|
const stream = testStream();
|
||||||
|
streams.push(stream);
|
||||||
|
return Promise.resolve(stream.response);
|
||||||
|
}
|
||||||
|
return Promise.resolve(routeFetch(url));
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a quest transition refetches the active filter; a day-complete does not", async () => {
|
||||||
|
render(
|
||||||
|
<TransitionsProvider>
|
||||||
|
<HistoryTab today={TODAY} />
|
||||||
|
</TransitionsProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByTestId("stat-on-time");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
|
||||||
|
);
|
||||||
|
await screen.findByTestId("stat-on-time");
|
||||||
|
await waitFor(() => expect(streams).toHaveLength(1));
|
||||||
|
const before = historyRequests().length;
|
||||||
|
|
||||||
|
streams[0].push(
|
||||||
|
frame("nestquest_child_day_complete", {
|
||||||
|
child_id: 2,
|
||||||
|
child_name: "Maeve",
|
||||||
|
quests_due: 4,
|
||||||
|
quests_completed: 4,
|
||||||
|
occurred_at: "2026-09-23T15:00:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
expect(historyRequests().length).toBe(before);
|
||||||
|
|
||||||
|
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
||||||
|
|
||||||
|
await waitFor(() => expect(historyRequests().length).toBe(before + 3));
|
||||||
|
const refetched = historyRequests().slice(before);
|
||||||
|
// One refetch (reversals + the two stats queries), same range and filter.
|
||||||
|
expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
|
||||||
|
"all",
|
||||||
|
"missed",
|
||||||
|
"reversals",
|
||||||
|
]);
|
||||||
|
for (const url of refetched) {
|
||||||
|
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
||||||
|
expect(url.searchParams.get("end")).toBe(TODAY);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
expect(historyRequests().length).toBe(before + 3);
|
||||||
|
expect(screen.queryByTestId("history-loading")).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
||||||
|
// Rows stay read-only (D-005): no row carries a control.
|
||||||
|
for (const row of screen.getAllByTestId("history-row")) {
|
||||||
|
expect(within(row).queryByRole("button")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ApiForbiddenError } from "../api/client";
|
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 { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
|
||||||
import { localTodayIso } from "../schedule/cycle";
|
import { localTodayIso } from "../schedule/cycle";
|
||||||
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
|
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
|
||||||
@@ -165,8 +167,18 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
|||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
const [exportError, setExportError] = useState<string | null>(null);
|
||||||
const exportingRef = useRef(false);
|
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));
|
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. */
|
/** Download the CSV for the active filter over the displayed range. */
|
||||||
async function exportCsv() {
|
async function exportCsv() {
|
||||||
if (exportingRef.current) return;
|
if (exportingRef.current) return;
|
||||||
@@ -190,7 +202,9 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setState({ kind: "loading" });
|
const silent = silentNext.current;
|
||||||
|
silentNext.current = false;
|
||||||
|
if (!silent) setState({ kind: "loading" });
|
||||||
loadHistory(filter, start, today)
|
loadHistory(filter, start, today)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) setState({ kind: "ready", data });
|
if (!cancelled) setState({ kind: "ready", data });
|
||||||
@@ -201,7 +215,8 @@ export default function HistoryTab({ today = localTodayIso() }: { today?: string
|
|||||||
error instanceof ApiForbiddenError
|
error instanceof ApiForbiddenError
|
||||||
? error.message
|
? error.message
|
||||||
: "The history could not be loaded. Check your connection and try again.";
|
: "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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -241,6 +241,37 @@
|
|||||||
color: var(--nq-brand-purple);
|
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 {
|
.today-empty {
|
||||||
padding: var(--nq-a-density-card-pad);
|
padding: var(--nq-a-density-card-pad);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
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 TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
||||||
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
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";
|
import { storeTokens } from "../auth/oidc";
|
||||||
|
|
||||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||||
@@ -221,3 +223,186 @@ describe("TodayTab", () => {
|
|||||||
expect(error.textContent).toContain("nestquest-admins");
|
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<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TodayTab Undo and live updates", () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
let snapshots: AdminSnapshot[];
|
||||||
|
let uncomplete: () => Promise<Response>;
|
||||||
|
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(<TodayTab />);
|
||||||
|
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<Response>();
|
||||||
|
uncomplete = () => pending.promise;
|
||||||
|
render(<TodayTab />);
|
||||||
|
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(<TodayTab />);
|
||||||
|
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(<TodayTab />);
|
||||||
|
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(
|
||||||
|
<TransitionsProvider>
|
||||||
|
<TodayTab />
|
||||||
|
</TransitionsProvider>,
|
||||||
|
);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
* 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 {
|
import {
|
||||||
fetchSnapshot,
|
fetchSnapshot,
|
||||||
type AdminSnapshot,
|
type AdminSnapshot,
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
type SnapshotInstance,
|
type SnapshotInstance,
|
||||||
} from "../api/snapshot";
|
} from "../api/snapshot";
|
||||||
import { ApiForbiddenError } from "../api/client";
|
import { ApiForbiddenError } from "../api/client";
|
||||||
|
import { ApiRequestError } from "../api/definitions";
|
||||||
|
import { uncompleteInstance } from "../api/instances";
|
||||||
|
import { useTransitions } from "../api/useTransitions";
|
||||||
import {
|
import {
|
||||||
formatClockTime,
|
formatClockTime,
|
||||||
formatDueTime,
|
formatDueTime,
|
||||||
@@ -144,11 +147,18 @@ function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
|
|||||||
return [...overdue, ...completed];
|
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 { instance, childName } = item;
|
||||||
const overdue = isOverdueOpen(instance);
|
const overdue = isOverdueOpen(instance);
|
||||||
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
|
const undoing = undo.pendingId === instance.id;
|
||||||
// The admin plane has no complete route yet, so both actions are inert here.
|
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
||||||
|
// yet, so that action is inert here.
|
||||||
return (
|
return (
|
||||||
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
||||||
<span
|
<span
|
||||||
@@ -173,12 +183,21 @@ function AttentionRow({ item }: { item: AttentionItem }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{overdue ? (
|
||||||
type="button"
|
<button type="button" className="today-action today-action--done">
|
||||||
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
|
Mark done
|
||||||
>
|
</button>
|
||||||
{overdue ? "Mark done" : "Undo"}
|
) : (
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="today-action today-action--undo"
|
||||||
|
disabled={undo.pendingId !== null}
|
||||||
|
aria-busy={undoing}
|
||||||
|
onClick={() => undo.onUndo(instance.id)}
|
||||||
|
>
|
||||||
|
{undoing ? "Undoing…" : "Undo"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -197,10 +216,25 @@ function PermissionNote() {
|
|||||||
export default function TodayTab() {
|
export default function TodayTab() {
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||||
const [attempt, setAttempt] = useState(0);
|
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<number | null>(null);
|
||||||
|
const undoingRef = useRef(false);
|
||||||
|
const [undoError, setUndoError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
silentNext.current = true;
|
||||||
|
setAttempt((n) => n + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
|
||||||
|
useTransitions(refresh);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setState({ kind: "loading" });
|
const silent = silentNext.current;
|
||||||
|
silentNext.current = false;
|
||||||
|
if (!silent) setState({ kind: "loading" });
|
||||||
fetchSnapshot()
|
fetchSnapshot()
|
||||||
.then((snapshot) => {
|
.then((snapshot) => {
|
||||||
if (!cancelled) setState({ kind: "ready", snapshot });
|
if (!cancelled) setState({ kind: "ready", snapshot });
|
||||||
@@ -211,13 +245,37 @@ export default function TodayTab() {
|
|||||||
error instanceof ApiForbiddenError
|
error instanceof ApiForbiddenError
|
||||||
? error.message
|
? error.message
|
||||||
: "Today's snapshot could not be loaded. Check your connection and try again.";
|
: "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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [attempt]);
|
}, [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 sub = "";
|
||||||
let body;
|
let body;
|
||||||
if (state.kind === "loading") {
|
if (state.kind === "loading") {
|
||||||
@@ -251,7 +309,11 @@ export default function TodayTab() {
|
|||||||
{attention.length > 0 ? (
|
{attention.length > 0 ? (
|
||||||
<ul className="today-card today-list" aria-label="Needs attention">
|
<ul className="today-card today-list" aria-label="Needs attention">
|
||||||
{attention.map((item) => (
|
{attention.map((item) => (
|
||||||
<AttentionRow key={item.instance.id} item={item} />
|
<AttentionRow
|
||||||
|
key={item.instance.id}
|
||||||
|
item={item}
|
||||||
|
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
@@ -270,6 +332,14 @@ export default function TodayTab() {
|
|||||||
data-testid="today-scroll"
|
data-testid="today-scroll"
|
||||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||||
>
|
>
|
||||||
|
{undoError !== null ? (
|
||||||
|
<div className="today-action-error" role="alert" data-testid="today-undo-error">
|
||||||
|
<span>{undoError}</span>
|
||||||
|
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{body}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user