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:
2026-09-23 23:40:10 -04:00
co-authored by Claude Opus 5.5
parent 75b2ac888a
commit 26f7d3a7d1
11 changed files with 942 additions and 54 deletions
+17 -2
View File
@@ -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<string | null>(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;