Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37fb02de57 | ||
|
|
4ff40f00a2 | ||
|
|
a808f89993 |
@@ -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
@@ -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
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user