Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 37fb02de57 Add the admin Today tab (compact design)
Render ADMIN-SPEC §2.1-2.6 from GET /api/v1/admin/snapshot: header with
cycle day, the Done/Remaining/Overdue stat row, child rows with progress
and away state, the needs-attention list, and the admin-only Undo
permission note. The scroll column ends with 92px bottom padding, and
loading and error states never leave the panel blank.

Mark done / Undo render per spec but are not wired (task e57facc2).
The override pill and "from panel" actor are omitted: the snapshot
carries neither field.

Task: e0a2b862-8c1e-4d5b-95ad-48b460bfeaa0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:07:38 -04:00
overseer 4ff40f00a2 Merge dev into feature/24ce7ebf (bring the Feature 21 admin snapshot route) 2026-09-23 20:03:05 -04:00
dev_agent a808f89993 Feature 21: Admin Today Snapshot API (#190) (#191)
Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
2026-09-23 20:01:25 -04:00
12 changed files with 1475 additions and 12 deletions
+17
View File
@@ -31,6 +31,23 @@
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
}
/* A built screen supplies its own header and 92px-padded scroll column. */
.admin-panel--screen {
padding: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.admin-placeholder {
margin: 0;
padding: 13px;
+12 -6
View File
@@ -1,9 +1,17 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import App from "./App";
describe("NestQuest Admin shell", () => {
afterEach(cleanup);
beforeEach(() => {
// The Today tab fetches the admin snapshot; keep the shell tests offline.
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the title and all four tab labels", () => {
render(<App />);
@@ -14,12 +22,10 @@ describe("NestQuest Admin shell", () => {
}
});
it("starts on the Today placeholder and switches on tab click", () => {
it("starts on the Today screen and switches on tab click", () => {
render(<App />);
expect(screen.getByRole("tabpanel").textContent).toContain(
"Today placeholder",
);
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
fireEvent.click(screen.getByRole("tab", { name: "History" }));
+14 -6
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import "./App.css";
import TodayTab from "./today/TodayTab";
const TABS = [
{ id: "today", label: "Today" },
@@ -12,22 +13,29 @@ type TabId = (typeof TABS)[number]["id"];
export default function App() {
const [active, setActive] = useState<TabId>("today");
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
// stays for placeholder tabs and is visually hidden otherwise.
const hasScreen = active === "today";
return (
<div className="admin-shell">
<header className="admin-header">
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
<h1 className="admin-title">NestQuest Admin</h1>
<p className="admin-subline">Admin console</p>
</header>
<main
className="admin-panel"
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
role="tabpanel"
aria-labelledby={`tab-${active}`}
>
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
{active === "today" ? (
<TodayTab />
) : (
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
arrive in later tasks.
</p>
)}
</main>
<nav className="admin-tabbar" aria-label="Admin sections">
{TABS.map((tab) => (
+55
View File
@@ -0,0 +1,55 @@
/**
* Typed client for GET /api/v1/admin/snapshot — today's household snapshot
* that drives the admin Today tab.
*/
import { apiFetch } from "./client";
export type InstanceWindow = "morning" | "afternoon" | "evening";
export type InstanceState = "open" | "completed" | "missed";
export interface SnapshotInstance {
id: number;
definition_id: number;
child_id: number;
title: string;
icon: string | null;
window: InstanceWindow;
/** Local wall-clock "HH:MM", or null when the definition has no due time. */
due_time: string | null;
state: InstanceState;
overdue: boolean;
/** UTC ISO timestamp of the completion event, when completed. */
completed_at: string | null;
on_time: boolean | null;
}
export interface SnapshotChild {
child_id: number;
child_name: string;
present: boolean;
/** "YYYY-MM-DD" of the next present day, or null. */
next_present: string | null;
due_today: number;
completed_today: number;
remaining_today: number;
completion_pct: number;
instances: SnapshotInstance[];
}
export interface AdminSnapshot {
/** "YYYY-MM-DD" in the household's local zone. */
today_iso: string;
/** 1-based day in the custody cycle. */
cycle_day: number;
children: SnapshotChild[];
}
export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
export async function fetchSnapshot(): Promise<AdminSnapshot> {
const response = await apiFetch(SNAPSHOT_PATH);
if (!response.ok) {
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
}
return (await response.json()) as AdminSnapshot;
}
+21
View File
@@ -6,6 +6,8 @@
:root {
--nq-brand-blue: #3b3ab8;
--nq-brand-purple: #a035cc;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-a-page: #f4f4f6;
--nq-a-surface: #ffffff;
@@ -16,8 +18,27 @@
--nq-a-ink-secondary: #52525e;
--nq-a-ink-tertiary: #70707e;
--nq-a-success: #15803d;
--nq-a-danger: #dc2626;
--nq-a-danger-strong: #b91c1c;
--nq-a-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8;
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px;
--nq-a-size-stat: 22px;
--nq-a-size-meta: 11px;
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-tabbar-height: 70px;
--nq-a-tap-min: 44px;
--nq-a-radius-pill: 9999px;
--nq-a-progress-height: 6px;
}
* {
+300
View File
@@ -0,0 +1,300 @@
/* Today tab — compact density (design/ADMIN-SPEC.md §2). Mobile-first at the
390x844 reference; on wider viewports the column is capped and centred. */
.today-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
/* §2.1 header */
.today-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.today-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.today-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-icon-button {
flex: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
cursor: pointer;
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.today-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.today-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.today-list {
margin: 0;
padding: 0;
list-style: none;
}
.today-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
/* §2.2 stat row */
.today-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.today-stat {
padding: 12px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
.today-stat-value {
font-size: var(--nq-a-size-stat);
font-weight: 800;
line-height: 1.1;
}
.today-stat-value--done {
color: var(--nq-brand-blue);
}
.today-stat-value--remaining {
color: var(--nq-a-ink);
}
.today-stat-value--overdue {
color: var(--nq-a-danger);
}
.today-stat-label {
margin-top: 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
/* §2.3 child rows */
.today-child {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.today-avatar {
flex: none;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 7px;
background: var(--nq-brand-gradient);
color: #fff;
font-size: 13px;
font-weight: 800;
}
.today-child--away .today-avatar {
background: #9898a8;
}
.today-child-text {
flex: 1;
min-width: 0;
}
.today-child-name {
font-size: 14px;
font-weight: 700;
}
.today-child-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-child-meta--done {
color: var(--nq-a-success);
}
.today-progress {
flex: none;
width: 82px;
height: var(--nq-a-progress-height);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-divider);
overflow: hidden;
}
.today-progress-fill {
height: 100%;
border-radius: inherit;
background: var(--nq-brand-gradient-h);
}
/* §2.5 needs attention */
.today-section-label {
margin: 6px 0 -2px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.today-event {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.today-event-glyph {
flex: none;
display: flex;
color: var(--nq-a-success);
}
.today-event-glyph--overdue {
color: var(--nq-a-danger);
}
.today-event-text {
flex: 1;
min-width: 0;
}
.today-event-title {
font-size: 13px;
font-weight: 700;
}
.today-event-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-event-meta--overdue {
color: var(--nq-a-danger);
}
.today-action {
flex: none;
min-height: var(--nq-a-tap-min);
padding: 0 4px;
border: 0;
background: none;
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.today-action--done {
color: var(--nq-brand-blue);
}
.today-action--undo {
color: var(--nq-brand-purple);
}
.today-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* §2.6 permission note */
.today-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-info-bg);
color: var(--nq-a-info-ink);
font-size: 11.5px;
font-weight: 600;
}
.today-note-glyph {
flex: none;
display: flex;
color: var(--nq-a-info-icon);
}
/* Loading / error */
.today-status {
padding: 24px 13px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
text-align: center;
font-size: 13.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.today-status--error {
color: var(--nq-a-danger-strong);
}
.today-status--error p {
margin: 0 0 10px;
}
.today-retry {
padding: 8px 16px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
+223
View File
@@ -0,0 +1,223 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
import { storeTokens } from "../auth/oidc";
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
return {
id: 0,
definition_id: 1,
child_id: 1,
title: "Quest",
icon: null,
window: "morning",
due_time: null,
state: "open",
overdue: false,
completed_at: null,
on_time: null,
...overrides,
};
}
const SNAPSHOT: AdminSnapshot = {
today_iso: "2026-09-23",
cycle_day: 5,
children: [
{
child_id: 1,
child_name: "Declan",
present: true,
next_present: null,
due_today: 7,
completed_today: 3,
remaining_today: 4,
completion_pct: 43,
instances: [
instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
instance({
id: 12,
title: "Make bed",
state: "completed",
completed_at: "2026-09-23T11:04:00Z",
on_time: true,
}),
instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
// Overdue but already completed: not counted as overdue.
instance({
id: 14,
title: "Pack bag",
state: "completed",
overdue: true,
completed_at: "2026-09-23T10:00:00Z",
on_time: false,
}),
],
},
{
child_id: 2,
child_name: "Maeve",
present: true,
next_present: null,
due_today: 4,
completed_today: 4,
remaining_today: 0,
completion_pct: 100,
instances: [],
},
{
child_id: 3,
child_name: "Rory",
present: false,
next_present: "2026-10-01",
due_today: 0,
completed_today: 0,
remaining_today: 0,
completion_pct: 0,
instances: [],
},
],
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
async function renderReady() {
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
render(<TodayTab />);
await screen.findByText("Declan");
}
describe("TodayTab", () => {
beforeAll(() => {
// A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
vi.stubEnv("TZ", "America/Los_Angeles");
});
afterAll(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("fetches the admin snapshot with the Bearer token", async () => {
await renderReady();
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
expect(init.headers.Authorization).toBe("Bearer at-123");
});
it("header shows the weekday, date and cycle day", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("stat row shows done, remaining and overdue totals", async () => {
await renderReady();
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
});
it("present child row shows progress meta and fill", async () => {
await renderReady();
const row = screen.getByTestId("child-1");
expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
const bar = within(row).getByRole("progressbar");
expect(bar.getAttribute("aria-valuenow")).toBe("43");
expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
expect(row.className).not.toContain("today-child--away");
});
it("all-done child renders the success state", async () => {
await renderReady();
const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
expect(meta.className).toContain("today-child-meta--done");
});
it("away child shows the return date and the flat avatar", async () => {
await renderReady();
const row = screen.getByTestId("child-3");
expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
expect(row.className).toContain("today-child--away");
expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
});
it("needs-attention lists overdue and completed rows with their actions", async () => {
await renderReady();
const list = screen.getByRole("list", { name: "Needs attention" });
const overdue = within(list).getByTestId("attention-11");
expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
const completed = within(list).getByTestId("attention-12");
expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
// 11:04Z is 4:04 AM in Los Angeles (PDT).
expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
// Open, not-overdue instances do not need attention.
expect(within(list).queryByTestId("attention-13")).toBeNull();
// No actor in the payload, so no "from panel".
expect(list.textContent).not.toContain("from panel");
});
it("permission note carries the exact copy", async () => {
await renderReady();
expect(PERMISSION_NOTE).toBe(
"Undo is admin-only. A panel tap can never reverse a completion.",
);
expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
});
it("scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
});
it("shows a loading state while the snapshot is in flight", () => {
fetchMock.mockReturnValue(new Promise<Response>(() => {}));
render(<TodayTab />);
expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
});
it("shows an error state on a failed fetch and retries", async () => {
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
render(<TodayTab />);
const error = await screen.findByTestId("today-error");
expect(error.getAttribute("role")).toBe("alert");
expect(error.textContent).toContain("could not be loaded");
fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByText("Declan")).toBeTruthy();
});
it("shows the refusal message when the API answers 403", async () => {
fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
render(<TodayTab />);
const error = await screen.findByTestId("today-error");
expect(error.textContent).toContain("nestquest-admins");
});
});
+277
View File
@@ -0,0 +1,277 @@
/**
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
*/
import { useEffect, useState } from "react";
import {
fetchSnapshot,
type AdminSnapshot,
type SnapshotChild,
type SnapshotInstance,
} from "../api/snapshot";
import { ApiForbiddenError } from "../api/client";
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 }: { sub: string }) {
return (
<header className="today-header">
<div>
<h2 className="today-title">Today</h2>
<p className="today-sub">{sub}</p>
</div>
{/* Settings screen is a later task; the control is not wired yet. */}
<button type="button" className="today-icon-button" aria-label="Settings">
<SettingsGlyph />
</button>
</header>
);
}
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 (
<div className="today-stats">
{stats.map((stat) => (
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
<div className={`today-stat-value today-stat-value--${stat.key}`}>
{stat.value}
</div>
<div className="today-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}
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 (
<li
className={child.present ? "today-child" : "today-child today-child--away"}
data-testid={`child-${child.child_id}`}
>
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
{child.child_name.charAt(0).toUpperCase()}
</span>
<div className="today-child-text">
<div className="today-child-name">{child.child_name}</div>
<div className={metaClass}>{meta}</div>
</div>
<div
className="today-progress"
role="progressbar"
aria-label={`${child.child_name} progress`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
</div>
</li>
);
}
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];
}
function AttentionRow({ item }: { item: AttentionItem }) {
const { instance, childName } = item;
const overdue = isOverdueOpen(instance);
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
// The admin plane has no complete route yet, so both actions are inert here.
return (
<li className="today-event" data-testid={`attention-${instance.id}`}>
<span
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
>
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
</span>
<div className="today-event-text">
<div className="today-event-title">
{instance.title} · {childName}
</div>
{overdue ? (
<div className="today-event-meta today-event-meta--overdue">
{instance.due_time
? `Overdue since ${formatDueTime(instance.due_time)}`
: "Overdue"}
</div>
) : (
// The snapshot carries no completion actor, so "from panel" is not printed.
<div className="today-event-meta">
Completed {formatClockTime(instance.completed_at as string)}
</div>
)}
</div>
<button
type="button"
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
>
{overdue ? "Mark done" : "Undo"}
</button>
</li>
);
}
function PermissionNote() {
return (
<div className="today-note" data-testid="permission-note">
<span className="today-note-glyph">
<InfoGlyph />
</span>
<span>{PERMISSION_NOTE}</span>
</div>
);
}
export default function TodayTab() {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
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.";
setState({ kind: "error", message });
});
return () => {
cancelled = true;
};
}, [attempt]);
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="today-status" role="status" data-testid="today-loading">
Loading today…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="today-status today-status--error" role="alert" data-testid="today-error">
<p>{state.message}</p>
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else {
const { snapshot } = state;
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
const attention = attentionItems(snapshot);
body = (
<>
<StatRow snapshot={snapshot} />
<ul className="today-card today-list" aria-label="Children">
{snapshot.children.map((child) => (
<ChildRow key={child.child_id} child={child} />
))}
</ul>
<h3 className="today-section-label">Needs attention</h3>
{attention.length > 0 ? (
<ul className="today-card today-list" aria-label="Needs attention">
{attention.map((item) => (
<AttentionRow key={item.instance.id} item={item} />
))}
</ul>
) : (
<div className="today-card today-empty">Nothing needs attention.</div>
)}
<PermissionNote />
</>
);
}
return (
<div className="today-screen">
<ScreenHeader sub={sub} />
<div
className="today-scroll"
data-testid="today-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Date/time formatting for the Today tab.
*
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
* in UTC, so the printed day never shifts with the viewer's offset — unlike
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
* day in negative-offset zones.
*/
const LOCALE = "en-US";
function calendarDate(iso: string): Date {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
const [, year, month, day] = match;
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
}
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
return new Intl.DateTimeFormat(LOCALE, options)
.format(date)
.replace(/ /g, " ");
}
/** "Wednesday, September 23" */
export function formatLongDate(iso: string): string {
const date = calendarDate(iso);
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
return `${weekday}, ${monthDay}`;
}
/** "Sep 23" */
export function formatShortDate(iso: string): string {
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
}
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
export function formatDueTime(hhmm: string): string {
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
if (!match) return hhmm;
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
}
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
export function formatClockTime(isoTimestamp: string): string {
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
* shipped Lucide subset is task b50b048f; these keep this screen CDN-free
* until then.
*/
import type { ReactNode } from "react";
function Glyph({ size, children }: { size: number; children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{children}
</svg>
);
}
export function SettingsGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
</Glyph>
);
}
export function InfoGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</Glyph>
);
}
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</Glyph>
);
}
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</Glyph>
);
}
+117
View File
@@ -116,6 +116,22 @@ owns every rule so the two routes cannot drift:
a ``Content-Disposition`` attachment filename and the documented,
stable header row (Admin spec §5 event-row fields).
The snapshot route (task ef3f4877) is the admin-plane twin of the
panel's ``GET /api/v1/panel/snapshot`` for the admin PWA's Today tab —
the PWA is internet-facing and never holds the panel service token
(D-012), so it reads the same snapshot here:
- ``GET /snapshot`` threads ONE clock read (:func:`_local_now`) into
:func:`nestquest_core.snapshot.build_snapshot` and shapes each
child's instances through
:func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True`` — admin payloads KEEP missed instances with
their ``missed`` state (D-009), where the panel omits them. The
body is the panel's documented shape (``today_iso``, ``cycle_day``,
``children`` with the per-child presence, rollup counts and
``instances``); the builder, the state derivation and the shaping
all live in the core.
The settings routes (task 2b3de7e5) follow the same pattern over
:mod:`nestquest_core.settings_store` — the API's OWN durable settings
store. The API now OWNS settings in the database (a validated JSON
@@ -254,6 +270,7 @@ from api.nestquest_core import (
core_recurrence,
core_settings,
core_settings_store,
core_snapshot,
core_sweep,
)
@@ -683,6 +700,59 @@ class AdminMissedSweepResponse(BaseModel):
fired: int
class AdminInstancePayload(BaseModel):
"""One instance in the admin snapshot payload.
Mirrors :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=True``: ``state`` is ``open`` | ``completed`` |
``missed`` (admin payloads keep missed instances, D-009),
``on_time`` passes the completion event's flag through verbatim
(``None`` unless completed), and ``completed_at`` is the event's
UTC timestamp when completed.
"""
id: int
definition_id: int
child_id: int
title: str
icon: str | None
window: str
due_time: str | None
state: str
overdue: bool
completed_at: str | None
on_time: bool | None
class AdminChildSnapshotPayload(BaseModel):
"""One child's day: presence, rollup counts, and today's instances."""
child_id: int
child_name: str
present: bool
#: ISO date of the child's next present day while they are away;
#: ``None`` when the child is present today.
next_present: str | None
due_today: int
completed_today: int
remaining_today: int
completion_pct: int
instances: list[AdminInstancePayload]
class AdminSnapshotResponse(BaseModel):
"""Today's whole household snapshot for the admin Today tab.
The panel snapshot's shape (``today_iso``, ``cycle_day``,
``children`` in the household's sort order), with missed instances
kept in each child's ``instances``.
"""
today_iso: str
cycle_day: int
children: list[AdminChildSnapshotPayload]
#: 404 detail for a child id the core layer reports as non-existent.
_CHILD_NOT_FOUND_DETAIL = "Child not found"
@@ -1729,4 +1799,51 @@ async def admin_run_missed_sweep(
return AdminMissedSweepResponse(fired=len(events))
@router.get(
"/snapshot",
summary="Today's household snapshot",
response_model=AdminSnapshotResponse,
)
async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
"""Return today's full household snapshot for the admin Today tab.
Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this
handler performs NO auth of its own). Thin adapter: ONE clock read
(:func:`_local_now`) goes into
:func:`nestquest_core.snapshot.build_snapshot` against the live
database, and each child's instances are shaped with
``instance_payload(include_missed=True)`` — missed instances are
KEPT in the admin payload (D-009) — into
:class:`AdminSnapshotResponse`.
"""
state: DatabaseState = request.app.state.db
snapshot = await core_snapshot.build_snapshot(
state.database, core_settings.NestQuestSettings(), _local_now()
)
return AdminSnapshotResponse(
today_iso=snapshot.today_iso,
cycle_day=snapshot.cycle_day,
children=[
AdminChildSnapshotPayload(
child_id=child.child_id,
child_name=child.child_name,
present=child.present,
next_present=child.next_present,
due_today=child.due_today,
completed_today=child.completed_today,
remaining_today=child.remaining_today,
completion_pct=child.completion_pct,
instances=[
AdminInstancePayload.model_validate(instance)
for instance in core_snapshot.instance_payload(
child.instances, include_missed=True
)
],
)
for child in snapshot.children
],
)
__all__ = ["router"]
+328
View File
@@ -0,0 +1,328 @@
"""Admin-plane snapshot route tests (task ef3f4877).
These tests exercise ``GET /api/v1/admin/snapshot`` against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely
authenticates with NO network access. The household is seeded with
:func:`tests.test_api_panel._seed_household` through the SAME core
copy the app uses (``nestquest_core.*``), and both routes' single
clock read (``_local_now``) is pinned to noon so nothing straddles
midnight.
Proven per the done-condition:
- An admin JWT returns 200 with the documented shape: ``today_iso``,
``cycle_day`` and, per child, the presence fields, the rollup counts
and ``instances`` in the core ``instance_payload`` shape.
- A MISSED instance is KEPT in the admin payload (D-009) and still
OMITTED from the panel payload. The core builder lists only
instances due on ``now.date()``, and ``derive_state`` calls an
instance missed only when its due date is BEFORE that day — so a
real snapshot never carries a missed view. The test therefore
wraps the real builder to add the seeded past-due instance as a
view whose state the core's own ``derive_state`` computes
(``missed``), and reads it through both routes.
- Auth runs through the router's ONE ``require_admin`` dependency: no
credential is 401, the panel service token is refused outright
(403, the credential-type detail), and a non-admin JWT is 403.
- The handler performs exactly ONE clock read per request.
"""
from __future__ import annotations
import dataclasses
import datetime
import importlib
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
AdminRunner,
jwks_for,
make_token,
)
from tests.test_api_panel import _seed_household
# The completion layer through the SAME core copy the app uses — the
# missed test derives its view's state with the core's own rule.
_completion = importlib.import_module("nestquest_core.completion")
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _panel_headers() -> dict[str, str]:
"""The Authorization header carrying the harness panel token."""
return {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
@pytest.fixture
async def snapshot_household(
temp_db_path: str, monkeypatch
) -> SimpleNamespace:
"""A seeded household served by the admin-plane app, clock pinned.
``today`` comes from one host clock read; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). Both
the admin and the panel route's ``_local_now`` return it, and the
admin one counts its calls.
"""
today = datetime.datetime.now().astimezone().date()
pinned_now = datetime.datetime(
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
)
seed = await _seed_household(temp_db_path, today, pinned_now)
clock_reads: list[datetime.datetime] = []
def _admin_now() -> datetime.datetime:
clock_reads.append(pinned_now)
return pinned_now
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
client=client,
seed=seed,
today=today,
pinned_now=pinned_now,
pinned_utc_iso=pinned_now.astimezone(
datetime.timezone.utc
).isoformat(),
clock_reads=clock_reads,
)
# --- authentication: the ONE require_admin dependency ----------------------
async def test_snapshot_without_credential_is_401(
snapshot_household: SimpleNamespace,
) -> None:
"""No Authorization header is the uniform 401."""
response = await snapshot_household.client.get("/api/v1/admin/snapshot")
assert response.status_code == 401
async def test_snapshot_refuses_panel_service_token_outright(
snapshot_household: SimpleNamespace,
) -> None:
"""The panel service token is refused as a credential type (403).
403 with the credential-type detail — not the uniform 401 and not
the non-admin group detail — proves the token was refused outright,
never tried as a JWT.
"""
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot", headers=_panel_headers()
)
assert response.status_code == 403
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
async def test_snapshot_refuses_non_admin_jwt(
snapshot_household: SimpleNamespace,
) -> None:
"""A valid JWT without the nestquest-admins group is 403."""
non_admin = make_token(groups=("some-other-group",))
response = await snapshot_household.client.get(
"/api/v1/admin/snapshot",
headers={"Authorization": f"Bearer {non_admin}"},
)
assert response.status_code == 403
assert response.json()["detail"] == FORBIDDEN_DETAIL
# --- the payload -----------------------------------------------------------
async def test_admin_jwt_returns_documented_snapshot(
snapshot_household: SimpleNamespace,
) -> None:
"""An admin JWT returns 200 with the documented snapshot shape.
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household.
"""
client = snapshot_household.client
seed = snapshot_household.seed
response = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert response.status_code == 200
assert len(snapshot_household.clock_reads) == 1
body = response.json()
assert body == {
"today_iso": snapshot_household.today.isoformat(),
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
"cycle_day": 2,
"children": [
{
"child_id": seed.ada.id,
"child_name": "Ada",
"present": False,
"next_present": seed.returns_date.isoformat(),
"due_today": 1,
"completed_today": 0,
"remaining_today": 1,
"completion_pct": 0,
"instances": [
{
"id": seed.pack_bag_instance.id,
"definition_id": seed.pack_bag_instance.definition_id,
"child_id": seed.ada.id,
"title": "Pack bag",
"icon": None,
"window": "morning",
"due_time": "10:00",
"state": "open",
"overdue": True,
"completed_at": None,
"on_time": None,
}
],
},
{
"child_id": seed.bo.id,
"child_name": "Bo",
"present": True,
"next_present": None,
"due_today": 1,
"completed_today": 1,
"remaining_today": 0,
"completion_pct": 100,
"instances": [
{
"id": seed.brush_teeth_instance.id,
"definition_id": (
seed.brush_teeth_instance.definition_id
),
"child_id": seed.bo.id,
"title": "Brush teeth",
"icon": None,
"window": "morning",
"due_time": "17:00",
"state": "completed",
"overdue": False,
"completed_at": snapshot_household.pinned_utc_iso,
"on_time": True,
}
],
},
],
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == body
async def test_admin_keeps_missed_instance_panel_omits_it(
snapshot_household: SimpleNamespace, monkeypatch
) -> None:
"""A missed view is KEPT by the admin route, OMITTED by the panel's.
The real builder is wrapped to add the seeded past-due Stale chore
instance to Ada's views, its state derived by the core's own
``derive_state`` (no events, due yesterday => ``missed``). Both
routes read the SAME wrapped snapshot, so the only difference is
each route's ``include_missed`` flag.
"""
client = snapshot_household.client
seed = snapshot_household.seed
stale = seed.stale_instance
missed_state = _completion.derive_state(
stale, None, snapshot_household.today
)
assert missed_state == "missed"
missed_view = core_snapshot.QuestInstanceView(
instance_id=stale.id,
definition_id=stale.definition_id,
child_id=stale.child_id,
title="Stale chore",
icon=None,
window=stale.window,
due_date=stale.due_date,
due_time=stale.due_time,
state=missed_state,
overdue=False,
completed_at=None,
was_on_time=None,
)
real_build_snapshot = core_snapshot.build_snapshot
async def _with_missed_view(database, settings, now):
snapshot = await real_build_snapshot(database, settings, now)
return dataclasses.replace(
snapshot,
children=tuple(
dataclasses.replace(
child, instances=(*child.instances, missed_view)
)
if child.child_id == seed.ada.id
else child
for child in snapshot.children
),
)
monkeypatch.setattr(core_snapshot, "build_snapshot", _with_missed_view)
admin = await client.get(
"/api/v1/admin/snapshot", headers=_admin_headers()
)
assert admin.status_code == 200
admin_ada = admin.json()["children"][0]
assert admin_ada["child_id"] == seed.ada.id
assert [instance["id"] for instance in admin_ada["instances"]] == [
seed.pack_bag_instance.id,
stale.id,
]
assert admin_ada["instances"][1] == {
"id": stale.id,
"definition_id": stale.definition_id,
"child_id": seed.ada.id,
"title": "Stale chore",
"icon": None,
"window": "morning",
"due_time": "09:00",
"state": "missed",
"overdue": False,
"completed_at": None,
"on_time": None,
}
panel = await client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
panel_ada = panel.json()["children"][0]
assert [instance["id"] for instance in panel_ada["instances"]] == [
seed.pack_bag_instance.id
], "the panel payload must still omit missed instances"