/** * Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6). */ import { useEffect, useRef, useState } from "react"; import { fetchSnapshot, type AdminSnapshot, type SnapshotChild, 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 SettingsScreen from "../settings/SettingsScreen"; import { formatClockTime, formatDueTime, formatLongDate, formatShortDate, } from "./format"; import { AlertCircleGlyph, CheckCircleGlyph, InfoGlyph, SettingsGlyph, } from "./glyphs"; import "./TodayTab.css"; const CYCLE_LENGTH_DAYS = 14; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ export const SCROLL_BOTTOM_PADDING = "92px"; export const PERMISSION_NOTE = "Undo is admin-only. A panel tap can never reverse a completion."; type LoadState = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; snapshot: AdminSnapshot }; function isOverdueOpen(instance: SnapshotInstance): boolean { return instance.overdue && instance.state === "open"; } function ScreenHeader({ sub, onSettings }: { sub: string; onSettings: () => void }) { return (

Today

{sub}

{/* Opens the Settings screen. Only its Children section exists so far. TODO: horizon days, day rollover time and notification config are a separate later task. */}
); } function StatRow({ snapshot }: { snapshot: AdminSnapshot }) { const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0); const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0); const overdue = snapshot.children.reduce( (sum, c) => sum + c.instances.filter(isOverdueOpen).length, 0, ); const stats = [ { key: "done", label: "Done", value: done }, { key: "remaining", label: "Remaining", value: remaining }, { key: "overdue", label: "Overdue", value: overdue }, ]; return (
{stats.map((stat) => (
{stat.value}
{stat.label}
))}
); } function ChildRow({ child }: { child: SnapshotChild }) { const overdue = child.instances.filter(isOverdueOpen).length; const allDone = child.due_today > 0 && child.completed_today === child.due_today; let meta: string; let metaClass = "today-child-meta"; if (!child.present) { meta = child.next_present ? `Away · returns ${formatShortDate(child.next_present)}` : "Away"; } else if (allDone) { meta = `${child.completed_today} of ${child.due_today} · all done`; metaClass += " today-child-meta--done"; } else { meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`; } const pct = Math.max(0, Math.min(100, child.completion_pct)); // ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence // comes from a presence override. The snapshot carries no field saying // where an absence came from, so the pill is omitted rather than guessed. return (
  • {child.child_name}
    {meta}
  • ); } interface AttentionItem { instance: SnapshotInstance; childName: string; } function attentionItems(snapshot: AdminSnapshot): AttentionItem[] { const all = snapshot.children.flatMap((child) => child.instances.map((instance) => ({ instance, childName: child.child_name })), ); const overdue = all.filter((item) => isOverdueOpen(item.instance)); const completed = all .filter((item) => item.instance.state === "completed" && item.instance.completed_at) .sort((a, b) => (b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""), ); return [...overdue, ...completed]; } 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); 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 ? : }
    {instance.title} · {childName}
    {overdue ? (
    {instance.due_time ? `Overdue since ${formatDueTime(instance.due_time)}` : "Overdue"}
    ) : ( // The snapshot carries no completion actor, so "from panel" is not printed.
    Completed {formatClockTime(instance.completed_at as string)}
    )}
    {overdue ? ( ) : ( )}
  • ); } function PermissionNote() { return (
    {PERMISSION_NOTE}
    ); } 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); const [settingsOpen, setSettingsOpen] = useState(false); 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; const silent = silentNext.current; silentNext.current = false; if (!silent) setState({ kind: "loading" }); fetchSnapshot() .then((snapshot) => { if (!cancelled) setState({ kind: "ready", snapshot }); }) .catch((error: unknown) => { if (cancelled) return; const message = error instanceof ApiForbiddenError ? error.message : "Today's snapshot could not be loaded. Check your connection and try again."; // 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") { body = (
    Loading today…
    ); } else if (state.kind === "error") { body = (

    {state.message}

    ); } else { const { snapshot } = state; sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`; const attention = attentionItems(snapshot); body = ( <>
      {snapshot.children.map((child) => ( ))}

    Needs attention

    {attention.length > 0 ? (
      {attention.map((item) => ( void undo(id) }} /> ))}
    ) : (
    Nothing needs attention.
    )} ); } return (
    setSettingsOpen(true)} />
    {undoError !== null ? (
    {undoError}
    ) : null} {body}
    {settingsOpen ? ( { setSettingsOpen(false); // Child names and order feed the snapshot. refresh(); }} /> ) : null}
    ); }