Compare commits

..
Author SHA1 Message Date
overseerandClaude Opus 5.5 8d74efd495 Hide a previous rule's preview while the edited rule is fetched
Tag each preview result with the rule it answers and render it only while
that rule is still the draft's, so a rule edit clears the old dates or error
immediately. Cover window and assignee edits not requesting, and the 300 ms
debounce boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:25:23 -04:00
overseerandClaude Opus 5.5 a105b2b160 Preview a definition's next occurrences in the edit sheet
The edit sheet now shows the next five dates the backend would materialize
for the draft rule ("Next: Mon 5 Oct · Thu 8 Oct · …"), fetched from
POST /api/v1/admin/quest-definitions/occurrences-preview. Requests are
debounced 300 ms, fire only when the built rule changes, skip incomplete
drafts, drop superseded responses, and a failure shows a non-blocking note.

Task 85b2f166-d9da-45b5-928e-3ff4d2cc6d89

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:19:29 -04:00
20 changed files with 12 additions and 3212 deletions
-10
View File
@@ -8,7 +8,6 @@
"name": "nestquest-admin",
"version": "0.0.0",
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -2258,15 +2257,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+1 -2
View File
@@ -10,7 +10,6 @@
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^1.47.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -25,4 +24,4 @@
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
}
+1 -4
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import "./App.css";
import DefinitionsTab from "./definitions/DefinitionsTab";
import ScheduleTab from "./schedule/ScheduleTab";
import TodayTab from "./today/TodayTab";
const TABS = [
@@ -17,7 +16,7 @@ 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" || active === "tasks" || active === "schedule";
const hasScreen = active === "today" || active === "tasks";
return (
<div className="admin-shell">
@@ -34,8 +33,6 @@ export default function App() {
<TodayTab />
) : active === "tasks" ? (
<DefinitionsTab />
) : active === "schedule" ? (
<ScheduleTab />
) : (
<p className="admin-placeholder">
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
+1 -1
View File
@@ -103,7 +103,7 @@ function readDetail(body: unknown): string | null {
return null;
}
export async function readJson<T>(response: Response): Promise<T> {
async function readJson<T>(response: Response): Promise<T> {
if (!response.ok) {
let body: unknown = null;
try {
-126
View File
@@ -1,126 +0,0 @@
/**
* Typed client for the admin presence routes: a child's repeating schedule
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
* date-range overrides (/api/v1/admin/presence-overrides).
*/
import { apiFetch } from "./client";
import { CHILDREN_PATH, readJson } from "./definitions";
/**
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
*/
export type PresencePattern = Record<string, number[]>;
export interface PresenceScheduleBody {
cycle_length_weeks: number;
/** "YYYY-MM-DD"; pins cycle week 0. */
anchor_date: string;
pattern: PresencePattern;
}
export interface PresenceSchedule extends PresenceScheduleBody {
child_id: number;
}
export interface PresenceOverride {
id: number;
child_id: number;
start_date: string;
end_date: string;
is_present: boolean;
note: string | null;
}
export interface PresenceOverrideFilter {
childId?: number;
/** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
start?: string;
end?: string;
}
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
export function presenceSchedulePath(childId: number): string {
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
}
/** The child's schedule, or null when it has none (present every day). */
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
const body = await readJson<{ schedule: PresenceSchedule | null }>(
await apiFetch(presenceSchedulePath(childId)),
);
return body.schedule;
}
/** Set (upsert) the child's schedule; resolves to the schedule as stored. */
export async function savePresenceSchedule(
childId: number,
body: PresenceScheduleBody,
): Promise<PresenceSchedule> {
return readJson<PresenceSchedule>(
await apiFetch(presenceSchedulePath(childId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
export async function fetchPresenceOverrides(
filter: PresenceOverrideFilter = {},
): Promise<PresenceOverride[]> {
const params = new URLSearchParams();
if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
if (filter.start !== undefined) params.set("start", filter.start);
if (filter.end !== undefined) params.set("end", filter.end);
const query = params.toString();
const body = await readJson<{ overrides: PresenceOverride[] }>(
await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
);
return body.overrides;
}
export interface PresenceOverrideRange {
child_id: number;
/** "YYYY-MM-DD", inclusive. */
start_date: string;
/** "YYYY-MM-DD", inclusive; never before start_date. */
end_date: string;
is_present: boolean;
}
export interface PresenceOverrideBody extends PresenceOverrideRange {
note?: string;
}
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
function postJson(path: string, body: unknown): Promise<Response> {
return apiFetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/**
* How many open upcoming tasks saving this override would remove. Computed
* by the API before anything is saved; completed instances never count.
*/
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
const body = await readJson<{ removed: number }>(
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
);
return body.removed;
}
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
}
export async function deletePresenceOverride(overrideId: number): Promise<void> {
await readJson<{ status: string }>(
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
);
}
-24
View File
@@ -414,30 +414,6 @@
gap: 6px;
}
.defs-icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
gap: 6px;
}
.defs-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--nq-a-tap-min);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
cursor: pointer;
}
.defs-icon--on {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.defs-due-times {
display: flex;
flex-direction: column;
+4 -171
View File
@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import DefinitionsTab from "./DefinitionsTab";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
import { storeTokens } from "../auth/oidc";
@@ -135,7 +134,7 @@ function fakeApi(initial: QuestDefinition[]) {
id: 99,
title: body.title,
description: null,
icon: body.icon ?? null,
icon: null,
is_active: true,
skip_on_away: body.skip_on_away,
rule: body.rule,
@@ -159,7 +158,6 @@ function fakeApi(initial: QuestDefinition[]) {
? {
...d,
title: body.title ?? d.title,
icon: "icon" in body ? body.icon : d.icon,
skip_on_away: body.skip_on_away ?? d.skip_on_away,
rule: body.rule ?? d.rule,
// Like the API: a sent list replaces the whole set; an absent one keeps it.
@@ -333,7 +331,6 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions",
body: {
title: "Feed the cat",
icon: null,
rule: {
rule_type: "weekly",
interval: 2,
@@ -422,7 +419,6 @@ describe("DefinitionsTab", () => {
path: "/api/v1/admin/quest-definitions/11",
body: {
title: "Take out the bins",
icon: null,
rule: {
rule_type: "monthly_day",
interval: 1,
@@ -440,6 +436,9 @@ describe("DefinitionsTab", () => {
},
},
]);
// The icon is never sent, so it is carried through unchanged.
expect(api.writes()[0].body).not.toHaveProperty("icon");
await screen.findByText("Take out the bins");
expect(api.listFetches()).toBe(2);
expect(screen.queryByRole("dialog")).toBeNull();
@@ -919,169 +918,3 @@ describe("DefinitionsTab occurrence preview", () => {
expect(api.previews()).toHaveLength(1);
});
});
describe("DefinitionsTab icon picker", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
/** The Lucide name a row renders, or null for the generic fallback glyph. */
function rowIcon(row: HTMLElement): string | null {
const svg = row.querySelector(".defs-row-glyph svg");
expect(svg).not.toBeNull();
return svg!.getAttribute("data-icon");
}
function iconRadio(s: HTMLElement, label: string): HTMLElement {
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
name: label,
});
}
it("ships the curated subset, and every entry renders an SVG", () => {
expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([
"bed",
"bath",
"shower-head",
"toilet",
"brush",
"smile",
"shirt",
"washing-machine",
"utensils",
"cooking-pot",
"apple",
"brush-cleaning",
"spray-can",
"trash-2",
"recycle",
"sofa",
"house",
"backpack",
"book-open",
"pencil",
"music",
"dog",
"cat",
"fish",
"sprout",
"droplets",
"bike",
"volleyball",
"gamepad-2",
"star",
"heart",
"sun",
"moon",
"clock",
"calendar",
"list-checks",
]);
expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24);
expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40);
for (const { name } of DEFINITION_ICONS) {
const { container, unmount } = render(<DefinitionIcon name={name} />);
const svg = container.querySelector("svg");
expect(svg, name).not.toBeNull();
expect(svg!.getAttribute("data-icon")).toBe(name);
expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0);
unmount();
}
});
it("a chosen icon is POSTed for a new task and renders on its row", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "New" }));
const s = sheet();
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Make the bed" },
});
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
fireEvent.click(iconRadio(s, "Bed"));
expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true");
expect(iconRadio(s, "Bed").className).toContain("defs-icon--on");
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(api.writes()).toHaveLength(1);
expect(api.writes()[0].method).toBe("POST");
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
});
it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => {
await renderReady();
expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull();
fireEvent.click(screen.getByTestId("definition-11"));
const s = sheet();
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
fireEvent.click(iconRadio(s, "Bins"));
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
expect(api.writes()).toHaveLength(1);
const { method, path, body } = api.writes()[0];
expect(method).toBe("PATCH");
expect(path).toBe("/api/v1/admin/quest-definitions/11");
expect((body as { icon: string }).icon).toBe("trash-2");
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
expect(body).not.toHaveProperty("assignee_child_ids");
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
});
it("an unchanged icon is still sent on edit and shows as selected", async () => {
await renderReady();
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
fireEvent.click(screen.getByTestId("definition-10"));
const s = sheet();
expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true");
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
target: { value: "Brush teeth well" },
});
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
});
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" };
await renderReady([legacy]);
const row = screen.getByTestId("definition-20");
expect(rowIcon(row)).toBeNull();
fireEvent.click(row);
const s = sheet();
const group = within(s).getByRole("radiogroup", { name: "Icon" });
for (const radio of within(group).getAllByRole("radio")) {
expect(radio.getAttribute("aria-checked")).toBe("false");
}
await act(async () => {
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
});
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
});
});
+5 -38
View File
@@ -1,6 +1,8 @@
/**
* Definitions ("Tasks") tab — list + edit sheet with icon picker
* (design/ADMIN-SPEC.md §3.1–3.3).
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
*
* The icon picker (§3.3) is a separate task: an existing definition's icon is
* carried through unchanged by never sending it.
*/
import {
useEffect,
@@ -42,7 +44,6 @@ import {
PlusGlyph,
TaskGlyph,
} from "./glyphs";
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
import "./DefinitionsTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
@@ -77,8 +78,6 @@ const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "S
interface Draft {
title: string;
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
icon: string | null;
assigneeIds: number[];
repeats: Repeats;
interval: string;
@@ -118,7 +117,6 @@ function newDraft(activeChildren: AdminChild[]): Draft {
const today = new Date();
return {
title: "",
icon: null,
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
repeats: "daily",
interval: "1",
@@ -143,7 +141,6 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
return {
title: definition.title,
icon: definition.icon,
// Inactive children cannot be submitted (the API rejects them with 422).
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
repeats: REPEATS_BY_RULE[rule.rule_type],
@@ -236,7 +233,6 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
);
return {
title,
icon: draft.icon,
rule,
assignee_child_ids: [...draft.assigneeIds],
windows,
@@ -323,7 +319,7 @@ function DefinitionRow({
onClick={onOpen}
>
<span className="defs-row-glyph">
<DefinitionIcon name={definition.icon} />
<TaskGlyph />
</span>
<span className="defs-row-text">
<span className="defs-row-title">{definition.title}</span>
@@ -562,35 +558,6 @@ function EditSheet({
/>
</Field>
<Field label="Icon" id="defs-label-icon">
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
<button
type="button"
role="radio"
aria-checked={draft.icon === null}
aria-label="No icon"
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: null })}
>
<TaskGlyph size={18} />
</button>
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
<button
key={name}
type="button"
role="radio"
aria-checked={draft.icon === name}
aria-label={label}
data-icon-option={name}
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
onClick={() => update({ icon: name })}
>
<Icon size={18} aria-hidden="true" focusable="false" />
</button>
))}
</div>
</Field>
<Field label="Assigned children" id="defs-label-children">
<button
type="button"
-103
View File
@@ -1,103 +0,0 @@
/**
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
*/
import {
Apple,
Backpack,
Bath,
Bed,
Bike,
BookOpen,
Brush,
BrushCleaning,
Calendar,
Cat,
Clock,
CookingPot,
Dog,
Droplets,
Fish,
Gamepad2,
Heart,
House,
ListChecks,
Moon,
Music,
Pencil,
Recycle,
Shirt,
ShowerHead,
Smile,
Sofa,
SprayCan,
Sprout,
Star,
Sun,
Toilet,
Trash2,
Utensils,
Volleyball,
WashingMachine,
type LucideIcon,
} from "lucide-react";
import { TaskGlyph } from "./glyphs";
export interface DefinitionIconEntry {
name: string;
label: string;
Icon: LucideIcon;
}
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
{ name: "bed", label: "Bed", Icon: Bed },
{ name: "bath", label: "Bath", Icon: Bath },
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
{ name: "toilet", label: "Toilet", Icon: Toilet },
{ name: "brush", label: "Brush", Icon: Brush },
{ name: "smile", label: "Smile", Icon: Smile },
{ name: "shirt", label: "Clothes", Icon: Shirt },
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
{ name: "utensils", label: "Meal", Icon: Utensils },
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
{ name: "apple", label: "Snack", Icon: Apple },
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
{ name: "spray-can", label: "Clean", Icon: SprayCan },
{ name: "trash-2", label: "Bins", Icon: Trash2 },
{ name: "recycle", label: "Recycling", Icon: Recycle },
{ name: "sofa", label: "Tidy", Icon: Sofa },
{ name: "house", label: "Home", Icon: House },
{ name: "backpack", label: "School bag", Icon: Backpack },
{ name: "book-open", label: "Reading", Icon: BookOpen },
{ name: "pencil", label: "Homework", Icon: Pencil },
{ name: "music", label: "Music", Icon: Music },
{ name: "dog", label: "Dog", Icon: Dog },
{ name: "cat", label: "Cat", Icon: Cat },
{ name: "fish", label: "Fish", Icon: Fish },
{ name: "sprout", label: "Plants", Icon: Sprout },
{ name: "droplets", label: "Water", Icon: Droplets },
{ name: "bike", label: "Bike", Icon: Bike },
{ name: "volleyball", label: "Sport", Icon: Volleyball },
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
{ name: "star", label: "Star", Icon: Star },
{ name: "heart", label: "Heart", Icon: Heart },
{ name: "sun", label: "Morning", Icon: Sun },
{ name: "moon", label: "Bedtime", Icon: Moon },
{ name: "clock", label: "Clock", Icon: Clock },
{ name: "calendar", label: "Calendar", Icon: Calendar },
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
];
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
return name === null ? null : (BY_NAME.get(name) ?? null);
}
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
const entry = findDefinitionIcon(name);
if (!entry) return <TaskGlyph size={size} />;
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
}
-4
View File
@@ -23,10 +23,6 @@
--nq-a-danger: #dc2626;
--nq-a-danger-strong: #b91c1c;
--nq-a-danger-bg: #fee2e2;
--nq-a-warning-ink: #92400e;
--nq-a-warning-ink-2: #78350f;
--nq-a-warning-bg: #fef3c7;
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
--nq-a-info-bg: #dbeafe;
--nq-a-info-ink: #1e3a8a;
--nq-a-info-icon: #1d4ed8;
-220
View File
@@ -1,220 +0,0 @@
/* Presence override editor — roomy density pushed screen
(design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
.override-screen {
position: fixed;
inset: 0;
z-index: 30;
overflow-y: auto;
background: var(--nq-a-page);
}
.override-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
}
.override-back {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -6px 0 -6px -12px;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink);
cursor: pointer;
}
.override-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.override-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.override-scroll {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 640px;
margin: 0 auto;
padding: 16px 16px 24px;
}
/* Form card: roomy density, 18px padding, 16px gaps. */
.override-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: 12px;
}
.override-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.override-label {
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: var(--nq-a-track-label);
color: var(--nq-a-ink-secondary);
}
.override-segments {
display: grid;
gap: 6px;
}
.override-segments--3 {
grid-template-columns: repeat(3, 1fr);
}
.override-segments--2 {
grid-template-columns: repeat(2, 1fr);
}
.override-segment {
min-height: var(--nq-a-tap-min);
padding: 0 8px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.override-segment--gradient[aria-pressed="true"] {
border-color: transparent;
background: var(--nq-brand-gradient);
color: #fff;
}
.override-segment--ink[aria-pressed="true"] {
border-color: #18181d;
background: #18181d;
color: #fff;
}
.override-dates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.override-input {
width: 100%;
min-height: var(--nq-a-tap-min);
padding: 13px;
border: 1px solid var(--nq-a-border);
border-radius: 10px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 14px;
font-weight: 600;
}
.override-date {
font-weight: 700;
}
/* Consequence warning */
.override-warning {
display: flex;
gap: 10px;
padding: 14px;
border: 1px solid var(--nq-a-warning-border);
border-radius: 12px;
background: var(--nq-a-warning-bg);
}
.override-warning-glyph {
flex: none;
display: flex;
color: var(--nq-a-warning-ink);
}
.override-warning-headline {
margin: 0;
font-size: 13.5px;
font-weight: 700;
color: var(--nq-a-warning-ink);
}
.override-warning-body {
margin: 3px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-warning-ink-2);
}
.override-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--nq-a-density-radius);
background: var(--nq-a-danger-bg);
color: var(--nq-a-danger-strong);
font-size: 12.5px;
font-weight: 700;
}
/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
.override-footer {
display: flex;
gap: 10px;
}
.override-cancel,
.override-save {
padding: 15px 12px;
border-radius: 12px;
font-family: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.override-cancel {
flex: 1;
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.override-save {
flex: 2;
border: 0;
background: var(--nq-brand-gradient);
color: #fff;
}
.override-cancel:disabled,
.override-save:disabled {
opacity: 0.45;
cursor: default;
}
-533
View File
@@ -1,533 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import App from "../App";
import ScheduleTab from "./ScheduleTab";
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
import { storeTokens } from "../auth/oidc";
function child(id: number, display_name: string, sort_order: number): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
}
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
const DECLAN: PresenceSchedule = {
child_id: 1,
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
};
const INITIAL_OVERRIDES: PresenceOverride[] = [
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
];
const TODAY = "2026-09-23";
const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface Call {
method: string;
path: string;
body: unknown;
}
let overrides: PresenceOverride[];
let consequence: (body: Record<string, unknown>) => Response | Promise<Response>;
let createResponse: (body: Record<string, unknown>) => Response;
let fetchMock: ReturnType<typeof vi.fn>;
function calls(): Call[] {
return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
method: init?.method ?? "GET",
path: new URL(String(url), "http://x").pathname,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
}));
}
function consequenceBodies(): unknown[] {
return calls()
.filter((c) => c.path === CONSEQUENCE_PATH)
.map((c) => c.body);
}
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
const path = new URL(url, "http://x").pathname;
const method = init.method ?? "GET";
const body = init.body ? JSON.parse(String(init.body)) : undefined;
if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
if (deleteMatch && method === "DELETE") {
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
return jsonResponse({ status: "ok" });
}
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
if (scheduleMatch) {
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
}
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
return jsonResponse({ detail: "unexpected" }, 500);
}
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((r) => {
resolve = r;
});
return { promise, resolve };
}
const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
function headline(): string {
return screen.getByTestId("override-consequence-headline").textContent ?? "";
}
function warningBody(): string {
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
}
function saveButton(): HTMLButtonElement {
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
}
function setDate(label: "From" | "Through", value: string) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
}
async function openEditor() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Add" }));
await screen.findByRole("heading", { name: "Presence override" });
}
describe("OverrideEditor", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created: PresenceOverride = {
id: 50,
child_id: body.child_id as number,
start_date: body.start_date as string,
end_date: body.end_date as string,
is_present: body.is_present as boolean,
note: (body.note as string | undefined) ?? null,
};
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the pushed screen per ADMIN-SPEC §6", async () => {
await openEditor();
expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
["Declan", "true"],
["Maeve", "false"],
["Rory", "false"],
]);
const status = within(screen.getByRole("group", { name: "Status for these dates" }));
expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("shows the live consequence count from the API and recounts on every change", async () => {
await openEditor();
// Save is off until the count is on screen: the warning precedes saving.
expect(saveButton().disabled).toBe(true);
await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
expect(consequenceBodies()).toEqual([
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
]);
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
);
expect(saveButton().disabled).toBe(false);
consequence = () => jsonResponse({ removed: 9 });
setDate("Through", "2026-09-27");
await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
expect(consequenceBodies()[1]).toEqual({
child_id: 1,
start_date: TODAY,
end_date: "2026-09-27",
is_present: false,
});
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
consequence = () => jsonResponse({ removed: 2 });
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
expect(consequenceBodies()[2]).toEqual({
child_id: 2,
start_date: TODAY,
end_date: "2026-09-27",
is_present: false,
});
expect(warningBody()).toBe(
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
);
// A Home override removes nothing by definition: no request, count 0.
fireEvent.click(screen.getByRole("button", { name: "Home" }));
expect(headline()).toBe("This removes 0 upcoming tasks");
expect(warningBody()).toBe(
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(3);
});
it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
// The range holds 3 open instances and 1 already completed. The API
// excludes the completed one (D-005; proven by
// tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
// and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
// and the editor shows exactly that — it never counts days on its own.
consequence = () => jsonResponse({ removed: 3 });
await openEditor();
setDate("Through", "2026-09-25");
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
expect(warningBody()).toBe(
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
);
});
it("a stale consequence response never overwrites a newer one", async () => {
const first = deferred();
const second = deferred();
const pending = [first, second];
consequence = () => pending.shift()!.promise;
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "2026-09-26");
await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
second.resolve(jsonResponse({ removed: 5 }));
await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
first.resolve(jsonResponse({ removed: 99 }));
await pastDebounce();
expect(headline()).toBe("This removes 5 upcoming tasks");
});
it("debounces rapid edits into one request for the final range", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "2026-09-24");
setDate("Through", "2026-09-25");
setDate("Through", "2026-09-26");
await pastDebounce();
expect(consequenceBodies().slice(1)).toEqual([
{ child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
]);
});
it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("Through", "");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
setDate("Through", "2026-09-20"); // before From
expect(headline()).toBe("Choose a valid date range");
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("a malformed From date sends no consequence request and cannot be saved", async () => {
await openEditor();
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
setDate("From", "2026-02-30");
expect(headline()).toBe("Choose a valid date range");
expect(saveButton().disabled).toBe(true);
await pastDebounce();
expect(consequenceBodies()).toHaveLength(1);
fireEvent.click(saveButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
await openEditor();
setDate("From", "2026-09-24");
setDate("Through", "2026-09-26");
fireEvent.change(screen.getByLabelText("Reason (optional)"), {
target: { value: " Trip with grandparents " },
});
await waitFor(() => expect(saveButton().disabled).toBe(false));
const before = calls().length;
const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length;
fireEvent.click(saveButton());
const row = await screen.findByTestId("override-50");
expect(row.textContent).toContain("Declan away · Sep 24 – 26");
expect(row.textContent).toContain("Trip with grandparents");
expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
const after = calls().slice(before);
const writes = after.filter((c) => c.method !== "GET");
// D-005: saving sends ONLY the create request — never a delete of an
// instance or an override. The API's regenerate path keeps completed
// instances (tests/test_materialize.py::
// test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
expect(writes).toEqual([
{
method: "POST",
path: OVERRIDES_PATH,
body: {
child_id: 1,
start_date: "2026-09-24",
end_date: "2026-09-26",
is_present: false,
note: "Trip with grandparents",
},
},
]);
expect(after.some((c) => c.method === "DELETE")).toBe(false);
// The overrides and the presence schedules are refetched for the grid.
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
schedulesBefore,
);
// The grid reflects the new override.
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
"override-away",
);
});
it("omits an empty reason and saves a Home override", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Home" }));
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
await screen.findByTestId("override-50");
const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
expect(post?.body).toEqual({
child_id: 1,
start_date: TODAY,
end_date: TODAY,
is_present: true,
});
});
it("a 422 shows the API detail and keeps the form", async () => {
createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
await openEditor();
setDate("Through", "2026-09-25");
fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
const alert = await screen.findByTestId("override-error");
expect(alert.textContent).toBe("overlaps an existing override");
expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
expect(saveButton().disabled).toBe(false);
});
it("Cancel and Back close the editor without writing", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Add" }));
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Add opens the editor for the selected child", async () => {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
fireEvent.click(screen.getByRole("button", { name: "Add" }));
const kids = within(await screen.findByRole("group", { name: "Child" }));
expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
await waitFor(() =>
expect(consequenceBodies()).toEqual([
{ child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
]),
);
});
it("the trash button deletes the override only after confirm", async () => {
render(<ScheduleTab today={TODAY} />);
const row = await screen.findByTestId("override-41");
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
expect(calls().some((c) => c.method === "DELETE")).toBe(false);
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
const confirm = within(row).getByRole("group", { name: "Confirm delete" });
fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
expect(calls().filter((c) => c.method === "DELETE")).toEqual([
{ method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
]);
expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
});
});
describe("OverrideEditor focus", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
overrides = [...INITIAL_OVERRIDES];
consequence = () => jsonResponse({ removed: 4 });
createResponse = (body) => {
const created = { id: 50, note: null, ...body } as PresenceOverride;
overrides = [...overrides, created];
return jsonResponse(created, 201);
};
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function dialog(): HTMLElement {
return screen.getByRole("dialog", { name: "Presence override" });
}
function addButton(): HTMLElement {
return screen.getByRole("button", { name: "Add" });
}
it("moves focus into the editor on open", async () => {
await openEditor();
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("keeps Tab and Shift+Tab inside the editor", async () => {
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
const back = screen.getByRole("button", { name: "Back" });
saveButton().focus();
fireEvent.keyDown(saveButton(), { key: "Tab" });
expect(document.activeElement).toBe(back);
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
const title = screen.getByRole("heading", { name: "Presence override" });
title.focus();
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(saveButton());
});
it("makes the tab bar inert while open and restores it on close", async () => {
render(<App />);
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
await screen.findByTestId("pattern-1");
fireEvent.click(addButton());
await screen.findByRole("heading", { name: "Presence override" });
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
expect(tabbar.hasAttribute("inert")).toBe(true);
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
expect(dialog().closest("[inert]")).toBeNull();
fireEvent.keyDown(dialog(), { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(tabbar.hasAttribute("inert")).toBe(false);
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
});
it("Escape cancels without writing and returns focus to Add", async () => {
await openEditor();
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
await screen.findByTestId("pattern-1");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
});
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
const gate = deferred();
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
const path = new URL(url, "http://x").pathname;
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
return routeFetch(url, init);
});
await openEditor();
await waitFor(() => expect(saveButton().disabled).toBe(false));
fireEvent.click(saveButton());
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
fireEvent.keyDown(dialog(), { key: "Escape" });
expect(screen.getByRole("dialog")).toBeTruthy();
gate.resolve(new Response(null));
await screen.findByTestId("override-50");
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.activeElement).toBe(addButton());
});
it("Back and Cancel return focus to Add", async () => {
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
fireEvent.click(addButton());
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
await screen.findByTestId("pattern-1");
expect(document.activeElement).toBe(addButton());
});
});
describe("range validation", () => {
it("accepts only strict ISO calendar dates in order", () => {
expect(isIsoDate("2026-09-23")).toBe(true);
expect(isIsoDate("2026-02-30")).toBe(false);
expect(isIsoDate("2026-9-3")).toBe(false);
expect(isIsoDate("")).toBe(false);
expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
expect(isValidRange("2026-09-23", "")).toBe(false);
});
});
-348
View File
@@ -1,348 +0,0 @@
/**
* Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
*
* The consequence warning is counted by the API (POST
* /presence-overrides/consequence) before anything is saved; Save stays off
* until that count is on screen. Saving only ever sends the create request:
* the API never removes an instance that already has a completion event (D-005).
*/
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, type AdminChild } from "../api/definitions";
import {
createPresenceOverride,
previewOverrideConsequence,
type PresenceOverride,
type PresenceOverrideBody,
} from "../api/presence";
import { dayNumber, isoFromDayNumber } from "./cycle";
import { dateRange } from "./format";
import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
import "./OverrideEditor.css";
/** How long the form must sit still before the consequence is recounted. */
export const CONSEQUENCE_DEBOUNCE_MS = 300;
/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
export function isIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return isoFromDayNumber(dayNumber(value)) === value;
}
export function isValidRange(start: string, end: string): boolean {
return isIsoDate(start) && isIsoDate(end) && start <= end;
}
type Consequence =
| { kind: "invalid" }
| { kind: "counting" }
| { kind: "ready"; removed: number }
| { kind: "error"; message: string };
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
}
const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
/**
* Make everything outside `root` inert (siblings of each ancestor up to
* <body>), so the tab bar behind the pushed screen cannot take focus.
* Returns the undo.
*/
function inertOutside(root: HTMLElement): () => void {
const made: HTMLElement[] = [];
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
for (const sibling of Array.from(node.parentElement.children)) {
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
sibling.setAttribute("inert", "");
sibling.setAttribute("aria-hidden", "true");
made.push(sibling);
}
}
return () => {
for (const el of made) {
el.removeAttribute("inert");
el.removeAttribute("aria-hidden");
}
};
}
function tasksPhrase(count: number): string {
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
}
export interface OverrideEditorProps {
/** Active children, in display order. */
childOptions: AdminChild[];
initialChildId: number;
/** "YYYY-MM-DD"; the date fields start here. */
today: string;
onCancel: () => void;
onSaved: (override: PresenceOverride) => void;
}
export default function OverrideEditor({
childOptions,
initialChildId,
today,
onCancel,
onSaved,
}: OverrideEditorProps) {
const [childId, setChildId] = useState(initialChildId);
const [isPresent, setIsPresent] = useState(false);
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState(today);
const [note, setNote] = useState("");
const [consequence, setConsequence] = useState<Consequence>({ kind: "counting" });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Every form change bumps the sequence; only the latest request may render.
const requestSeq = useRef(0);
const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
// Modal focus: move in on open and keep the background out of reach. The
// opener hands focus back to its Add control once this screen closes.
useEffect(() => {
titleRef.current?.focus();
return screenRef.current ? inertOutside(screenRef.current) : undefined;
}, []);
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") {
event.preventDefault();
if (!saving) onCancel();
return;
}
if (event.key !== "Tab" || !screenRef.current) return;
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
const outside = !focusable.includes(current as HTMLElement);
if (event.shiftKey && (current === first || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
}
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
const rangeValid = isValidRange(startDate, endDate);
useEffect(() => {
const seq = ++requestSeq.current;
if (!rangeValid) {
setConsequence({ kind: "invalid" });
return;
}
if (isPresent) {
// Keeping a child home removes nothing by definition.
setConsequence({ kind: "ready", removed: 0 });
return;
}
setConsequence({ kind: "counting" });
const timer = setTimeout(() => {
previewOverrideConsequence({
child_id: childId,
start_date: startDate,
end_date: endDate,
is_present: false,
})
.then((removed) => {
if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
})
.catch((err: unknown) => {
if (seq !== requestSeq.current) return;
setConsequence({
kind: "error",
message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
});
});
}, CONSEQUENCE_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [childId, startDate, endDate, isPresent, rangeValid]);
const canSave = rangeValid && consequence.kind === "ready" && !saving;
function save() {
if (!canSave) return;
const body: PresenceOverrideBody = {
child_id: childId,
start_date: startDate,
end_date: endDate,
is_present: isPresent,
};
const reason = note.trim();
if (reason) body.note = reason;
setSaving(true);
setError(null);
createPresenceOverride(body)
.then(onSaved)
.catch((err: unknown) => {
setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
setSaving(false);
});
}
let headline: string;
let detail: string;
const range = rangeValid ? dateRange(startDate, endDate) : "";
if (consequence.kind === "invalid") {
headline = "Choose a valid date range";
detail = "Through must be on or after From.";
} else if (consequence.kind === "counting") {
headline = "Counting upcoming tasks…";
detail = `${child.display_name} away ${range}. Completed days are untouched.`;
} else if (consequence.kind === "error") {
headline = "Upcoming tasks not counted";
detail = consequence.message;
} else {
headline = `This removes ${tasksPhrase(consequence.removed)}`;
detail = isPresent
? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
: `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
}
return (
<div
ref={screenRef}
className="override-screen"
role="dialog"
aria-modal="true"
aria-labelledby="override-title"
onKeyDown={onKeyDown}
>
<header className="override-header">
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
<ChevronLeftGlyph size={20} />
</button>
<div>
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
Presence override
</h2>
<p className="override-sub">Beats the custody pattern for these dates</p>
</div>
</header>
<div className="override-scroll">
<section className="override-card" aria-label="Override details">
<div className="override-field">
<span className="override-label" id="override-label-child">
Child
</span>
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
{childOptions.map((c) => (
<button
key={c.id}
type="button"
className="override-segment override-segment--gradient"
aria-pressed={c.id === childId}
onClick={() => setChildId(c.id)}
>
{c.display_name}
</button>
))}
</div>
</div>
<div className="override-field">
<span className="override-label" id="override-label-status">
Status for these dates
</span>
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={!isPresent}
onClick={() => setIsPresent(false)}
>
Away
</button>
<button
type="button"
className="override-segment override-segment--ink"
aria-pressed={isPresent}
onClick={() => setIsPresent(true)}
>
Home
</button>
</div>
</div>
<div className="override-dates">
<label className="override-field">
<span className="override-label">From</span>
<input
className="override-input override-date"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label className="override-field">
<span className="override-label">Through</span>
<input
className="override-input override-date"
type="date"
value={endDate}
min={isIsoDate(startDate) ? startDate : undefined}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
<label className="override-field">
<span className="override-label">Reason (optional)</span>
<input
className="override-input"
type="text"
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</label>
</section>
<section
className="override-warning"
aria-label="Consequence"
aria-live="polite"
data-testid="override-consequence"
data-state={consequence.kind}
>
<span className="override-warning-glyph">
<AlertCircleGlyph />
</span>
<div>
<p className="override-warning-headline" data-testid="override-consequence-headline">
{headline}
</p>
<p className="override-warning-body">{detail}</p>
</div>
</section>
{error ? (
<p className="override-error" role="alert" data-testid="override-error">
{error}
</p>
) : null}
<div className="override-footer">
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
Cancel
</button>
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
{saving ? "Saving…" : "Save override"}
</button>
</div>
</div>
</div>
);
}
-416
View File
@@ -1,416 +0,0 @@
/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
the 390x844 reference; on wider viewports the column is capped and centred. */
.schedule-screen {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.schedule-header {
background: var(--nq-a-surface);
border-bottom: 1px solid var(--nq-a-border);
padding: 14px 16px;
}
.schedule-title {
margin: 0;
font-size: var(--nq-a-size-screen);
font-weight: 800;
letter-spacing: -0.02em;
}
.schedule-sub {
margin: 2px 0 0;
min-height: 1.4em;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
.schedule-scroll {
display: flex;
flex-direction: column;
gap: var(--nq-a-density-gap);
padding: 14px 16px;
}
.schedule-card {
background: var(--nq-a-surface);
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-density-radius);
}
/* Child selector */
.schedule-children {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.schedule-child-chip {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--nq-a-border);
border-radius: var(--nq-a-radius-pill);
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.schedule-child-chip[aria-pressed="true"] {
border-color: var(--nq-brand-blue);
background: var(--nq-a-selected);
color: var(--nq-brand-blue);
}
/* Month grid card */
.schedule-month {
padding: var(--nq-a-density-card-pad);
}
.schedule-month-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.schedule-month-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.schedule-chevrons {
display: flex;
gap: 4px;
}
.schedule-chevron {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--nq-a-border);
border-radius: 6px;
background: var(--nq-a-surface);
color: var(--nq-a-ink-secondary);
cursor: pointer;
}
.schedule-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.schedule-weekday {
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--nq-a-ink-tertiary);
}
.schedule-day {
aspect-ratio: 1;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.schedule-day--home {
background: var(--nq-a-selected);
color: #201f7a;
}
.schedule-day--away {
background: var(--nq-a-page);
color: var(--nq-a-ink-secondary);
}
.schedule-day--override-away,
.schedule-day--override-home {
background: var(--nq-a-warning-bg);
color: var(--nq-a-warning-ink);
border-color: var(--nq-a-warning-border);
}
/* An override that keeps the child home reads with a dashed border. */
.schedule-day--override-home {
border-style: dashed;
}
.schedule-day--today {
background: var(--nq-brand-gradient);
border-color: transparent;
color: #fff;
font-weight: 800;
}
.schedule-legend {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.schedule-legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-swatch {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid transparent;
border-radius: 3px;
}
/* Pattern editing controls */
.schedule-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--nq-a-divider);
}
.schedule-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: var(--nq-a-size-label);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--nq-a-ink-secondary);
}
.schedule-field input,
.schedule-field select {
min-height: 36px;
padding: 0 8px;
border: 1px solid var(--nq-a-border);
border-radius: 6px;
background: var(--nq-a-surface);
color: var(--nq-a-ink);
font-family: inherit;
font-size: 13px;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
}
.schedule-save {
min-height: 36px;
margin-left: auto;
padding: 0 14px;
border: 0;
border-radius: var(--nq-a-density-radius);
background: var(--nq-brand-gradient);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.schedule-save:disabled {
opacity: 0.45;
cursor: default;
}
.schedule-save-status {
margin: 8px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-success);
}
.schedule-save-status--error {
color: var(--nq-a-danger-strong);
}
/* Presence pattern + overrides cards */
.schedule-section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.schedule-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);
}
.schedule-text-action {
padding: 4px 0 0;
border: 0;
background: none;
color: var(--nq-brand-blue);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
}
.schedule-list {
margin: 0;
padding: 0;
list-style: none;
}
.schedule-list > li + li {
border-top: 1px solid var(--nq-a-divider);
}
.schedule-row {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--nq-a-tap-min);
padding: var(--nq-a-density-row-pad);
}
.schedule-row-glyph {
flex: none;
display: flex;
color: var(--nq-a-warning-ink);
}
.schedule-row-text {
flex: 1;
min-width: 0;
}
.schedule-row-title {
font-size: 13px;
font-weight: 700;
}
.schedule-row-meta {
font-size: var(--nq-a-size-meta);
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-rule {
flex: none;
font-size: 12px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
.schedule-icon-action {
flex: none;
display: grid;
place-items: center;
width: var(--nq-a-tap-min);
height: var(--nq-a-tap-min);
margin: -8px -10px -8px 0;
padding: 0;
border: 0;
background: none;
color: var(--nq-a-ink-tertiary);
cursor: pointer;
}
.schedule-empty {
padding: var(--nq-a-density-card-pad);
font-size: 13px;
font-weight: 600;
color: var(--nq-a-ink-secondary);
}
/* Loading / error */
.schedule-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);
}
.schedule-status--error {
color: var(--nq-a-danger-strong);
}
.schedule-status--error p {
margin: 0 0 10px;
}
.schedule-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;
}
/* Inline delete confirmation in an override row */
.schedule-confirm {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.schedule-confirm-text {
font-size: 12px;
font-weight: 700;
color: var(--nq-a-ink-secondary);
}
.schedule-confirm-keep,
.schedule-confirm-delete {
min-height: 32px;
padding: 0 10px;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.schedule-confirm-keep {
border: 1px solid var(--nq-a-border);
background: var(--nq-a-surface);
color: var(--nq-a-ink);
}
.schedule-confirm-delete {
border: 0;
background: var(--nq-a-danger);
color: #fff;
}
-276
View File
@@ -1,276 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import ScheduleTab from "./ScheduleTab";
import type { AdminChild } from "../api/definitions";
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
import { storeTokens } from "../auth/oidc";
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
}
const CHILDREN = [
child(3, "Rory", 3),
child(1, "Declan", 1),
child(2, "Maeve", 2),
child(9, "Former", 4, false),
];
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
// Keys arrive as JSON strings, exactly as the API serializes them.
const DECLAN: PresenceSchedule = {
child_id: 1,
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
};
const MAEVE: PresenceSchedule = {
child_id: 2,
cycle_length_weeks: 4,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] },
};
const OVERRIDES: PresenceOverride[] = [
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
{ id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
{ id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
];
const TODAY = "2026-09-23";
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>;
let putResponse: (body: unknown) => Response;
function routeFetch(url: string, init: RequestInit = {}): Response {
const path = new URL(url, "http://x").pathname;
if (init.method === "PUT") return putResponse(JSON.parse(String(init.body)));
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
if (match) {
const id = Number(match[1]);
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
}
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
return jsonResponse({ detail: "unexpected" }, 500);
}
function day(date: string): HTMLElement {
const cell = document.querySelector<HTMLElement>(`[data-date="${date}"]`);
if (!cell) throw new Error(`no cell for ${date}`);
return cell;
}
async function renderReady() {
render(<ScheduleTab today={TODAY} />);
await screen.findByTestId("pattern-1");
}
describe("ScheduleTab", () => {
beforeEach(() => {
sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 });
putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) });
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("loads active children, their schedules and the overrides", async () => {
await renderReady();
const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname);
expect(urls).toContain("/api/v1/admin/children");
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
expect(urls).toContain("/api/v1/admin/presence-overrides");
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
});
it("header shows the cycle length and anchor", async () => {
await renderReady();
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy();
});
it("month grid renders home, away, override and today states from the anchor", async () => {
await renderReady();
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
const states: Record<string, string> = {
"2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
"2026-09-05": "home", // before the anchor: cycle week 1, Saturday
"2026-09-07": "home", // anchor: week 0
"2026-09-11": "override-away",
"2026-09-15": "away", // week 1, Tuesday
"2026-09-16": "override-home",
"2026-09-19": "home", // week 1, Saturday
"2026-09-22": "home", // wrap back to week 0
"2026-09-29": "away", // week 1 again
};
for (const [date, state] of Object.entries(states)) {
expect(day(date).dataset.state, date).toBe(state);
expect(day(date).className).toContain(`schedule-day--${state}`);
expect(day(date).className).not.toContain("schedule-day--today");
}
const today = day(TODAY);
expect(today.className).toContain("schedule-day--today");
expect(today.dataset.state).toBe("home");
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
// September 2026 starts on a Tuesday: one leading pad after the initials.
const grid = screen.getByRole("group", { name: "Month grid" });
expect(grid.children[7].tagName).toBe("SPAN");
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
const legend = screen.getByRole("list", { name: "Legend" });
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
"Home",
"Away",
"Override · away",
"Override · home",
"Today",
]);
});
it("navigates months with the chevrons", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
// Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
expect(day("2026-10-01").dataset.state).toBe("away");
expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
});
it("presence pattern card shows a rule per child", async () => {
await renderReady();
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
});
it("overrides card lists the selected child's overrides with inert actions", async () => {
await renderReady();
const list = screen.getByRole("list", { name: "Overrides" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
const away = within(list).getByTestId("override-41");
expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
expect(within(away).getByText("Dentist trip")).toBeTruthy();
expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
});
it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => {
await renderReady();
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
expect(day("2026-09-15").dataset.state).toBe("home");
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
await renderReady();
expect(day("2026-09-01").dataset.state).toBe("away");
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
// The draft already shows the edit, and every other week-1 Tuesday follows.
expect(day("2026-09-01").dataset.state).toBe("home");
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-08").dataset.state).toBe("home");
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
expect(put).toBeTruthy();
const [url, init] = put!;
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
expect(init.headers.Authorization).toBe("Bearer at-123");
expect(JSON.parse(init.body)).toEqual({
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] },
});
expect(day("2026-09-15").dataset.state).toBe("home");
expect(day("2026-09-29").dataset.state).toBe("home");
expect(day("2026-09-30").dataset.state).toBe("away");
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
true,
);
});
it("saves an edited anchor date and cycle length", async () => {
await renderReady();
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
await screen.findByText("Pattern saved.");
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
expect(JSON.parse(init.body)).toEqual({
cycle_length_weeks: 3,
anchor_date: "2026-09-14",
pattern: {
"0": [0, 1, 2, 3, 4, 5, 6],
"1": [5, 6],
"2": [0, 1, 2, 3, 4, 5, 6],
},
});
});
it("a 422 shows the API detail and keeps the draft", async () => {
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
await renderReady();
fireEvent.click(day("2026-09-01"));
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("anchor_date must be a Monday");
expect(day("2026-09-01").dataset.state).toBe("home");
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
false,
);
// The saved rule is unchanged.
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
});
it("scroll column ends with 92px bottom padding", async () => {
await renderReady();
expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
});
it("shows an error state on a failed load and retries", async () => {
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
render(<ScheduleTab today={TODAY} />);
const error = await screen.findByTestId("schedule-error");
expect(error.textContent).toContain("could not be loaded");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
});
});
-557
View File
@@ -1,557 +0,0 @@
/**
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4).
*
* Every day's cycle position comes from the anchor date (./cycle.ts), never
* from ISO week numbers or week parity (D-004).
*/
import { useEffect, useRef, useState } from "react";
import { ApiForbiddenError } from "../api/client";
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
import {
deletePresenceOverride,
fetchPresenceOverrides,
fetchPresenceSchedule,
savePresenceSchedule,
type PresenceOverride,
type PresenceSchedule,
type PresenceScheduleBody,
} from "../api/presence";
import {
cycleWeekIndex,
dayState,
fullPattern,
localTodayIso,
monthDates,
monthStart,
patternIncludes,
presenceRule,
resizePattern,
togglePatternDay,
weekdayOf,
type DayState,
} from "./cycle";
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
import {
CalendarCheckGlyph,
CalendarXGlyph,
ChevronLeftGlyph,
ChevronRightGlyph,
Trash2Glyph,
} from "./glyphs";
import OverrideEditor from "./OverrideEditor";
import "./ScheduleTab.css";
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
export const SCROLL_BOTTOM_PADDING = "92px";
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
export const STATE_LABELS: Record<DayState, string> = {
home: "Home",
away: "Away",
"override-away": "Override · away",
"override-home": "Override · home",
};
interface Loaded {
children: AdminChild[];
schedules: Record<number, PresenceSchedule | null>;
overrides: PresenceOverride[];
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: Loaded };
type DeleteState =
| { kind: "idle" }
| { kind: "confirming"; id: number }
| { kind: "deleting"; id: number }
| { kind: "error"; id: number; message: string };
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved" }
| { kind: "error"; message: string };
async function loadSchedule(): Promise<Loaded> {
const children = (await fetchChildren())
.filter((child) => child.is_active)
.sort((a, b) => a.sort_order - b.sort_order);
const [schedules, overrides] = await Promise.all([
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
fetchPresenceOverrides(),
]);
return {
children,
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
overrides,
};
}
function loadErrorMessage(error: unknown): string {
return error instanceof ApiForbiddenError
? error.message
: "The schedule could not be loaded. Check your connection and try again.";
}
function saveErrorMessage(
error: unknown,
fallback = "The pattern could not be saved. Check your connection and try again.",
): string {
if (error instanceof ApiForbiddenError) return error.message;
if (error instanceof ApiRequestError && error.detail) return error.detail;
return fallback;
}
function ScreenHeader({ sub }: { sub: string }) {
return (
<header className="schedule-header">
<h2 className="schedule-title">Schedule</h2>
<p className="schedule-sub">{sub}</p>
</header>
);
}
interface MonthGridProps {
month: string;
today: string;
schedule: PresenceScheduleBody;
/** Null when the child has no saved schedule and no edits: home every day. */
effective: PresenceScheduleBody | null;
overrides: PresenceOverride[];
onMonth: (delta: number) => void;
onToggle: (date: string) => void;
}
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
const dates = monthDates(month);
const leading = weekdayOf(month);
return (
<>
<div className="schedule-month-head">
<span className="schedule-month-label" data-testid="month-label">
{monthLabel(month)}
</span>
<div className="schedule-chevrons">
<button
type="button"
className="schedule-chevron"
aria-label="Previous month"
onClick={() => onMonth(-1)}
>
<ChevronLeftGlyph />
</button>
<button
type="button"
className="schedule-chevron"
aria-label="Next month"
onClick={() => onMonth(1)}
>
<ChevronRightGlyph />
</button>
</div>
</div>
<div className="schedule-grid" role="group" aria-label="Month grid">
{WEEKDAY_INITIALS.map((initial, i) => (
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
{initial}
</span>
))}
{Array.from({ length: leading }, (_, i) => (
<span key={`pad-${i}`} aria-hidden="true" />
))}
{dates.map((date) => {
const state = dayState(effective, overrides, date);
const isToday = date === today;
let className = `schedule-day schedule-day--${state}`;
if (isToday) className += " schedule-day--today";
return (
<button
key={date}
type="button"
className={className}
data-date={date}
data-state={state}
aria-pressed={patternIncludes(schedule, date)}
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
onClick={() => onToggle(date)}
>
{Number(date.slice(8))}
</button>
);
})}
</div>
<ul className="schedule-legend" aria-label="Legend">
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
<li key={state} className="schedule-legend-item">
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
{STATE_LABELS[state]}
</li>
))}
<li className="schedule-legend-item">
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
Today
</li>
</ul>
</>
);
}
interface OverrideRowProps {
override: PresenceOverride;
childName: string;
deletion: DeleteState;
onAskDelete: () => void;
onKeep: () => void;
onDelete: () => void;
}
function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
const mine =
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
return (
<li className="schedule-row" data-testid={`override-${override.id}`}>
<span className="schedule-row-glyph">
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
</span>
<div className="schedule-row-text">
<div className="schedule-row-title">
{childName} {override.is_present ? "home" : "away"} ·{" "}
{dateRange(override.start_date, override.end_date)}
</div>
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
</div>
{mine ? (
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
<span className="schedule-confirm-text">Delete override?</span>
<button
type="button"
className="schedule-confirm-keep"
onClick={onKeep}
disabled={deletion.kind === "deleting"}
>
Keep
</button>
<button
type="button"
className="schedule-confirm-delete"
onClick={onDelete}
disabled={deletion.kind === "deleting"}
>
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
</button>
</div>
) : (
<button
type="button"
className="schedule-icon-action"
aria-label="Delete override"
onClick={onAskDelete}
>
<Trash2Glyph />
</button>
)}
</li>
);
}
export interface ScheduleTabProps {
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
today?: string;
}
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
const [state, setState] = useState<LoadState>({ kind: "loading" });
const [attempt, setAttempt] = useState(0);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [month, setMonth] = useState(() => monthStart(today));
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
const [save, setSave] = useState<SaveState>({ kind: "idle" });
const [editorOpen, setEditorOpen] = useState(false);
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
const addRef = useRef<HTMLButtonElement>(null);
const editorWasOpen = useRef(false);
// The editor replaces this screen, so Add is remounted when it closes;
// hand focus back to it then.
useEffect(() => {
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
editorWasOpen.current = editorOpen;
}, [editorOpen]);
/** Reload children, schedules and overrides in place (no loading flash). */
const refresh = () =>
loadSchedule()
.then((data) => setState({ kind: "ready", data }))
.catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
loadSchedule()
.then((data) => {
if (cancelled) return;
setState({ kind: "ready", data });
setSelectedId((current) => current ?? data.children[0]?.id ?? null);
})
.catch((error: unknown) => {
if (cancelled) return;
setState({ kind: "error", message: loadErrorMessage(error) });
});
return () => {
cancelled = true;
};
}, [attempt]);
if (editorOpen && state.kind === "ready" && state.data.children.length > 0) {
const { children } = state.data;
return (
<OverrideEditor
childOptions={children}
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
today={today}
onCancel={() => setEditorOpen(false)}
onSaved={(override) => {
setEditorOpen(false);
setSelectedId(override.child_id);
void refresh();
}}
/>
);
}
let sub = "";
let body;
if (state.kind === "loading") {
body = (
<div className="schedule-status" role="status" data-testid="schedule-loading">
Loading schedule…
</div>
);
} else if (state.kind === "error") {
body = (
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
<p>{state.message}</p>
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
Try again
</button>
</div>
);
} else if (state.data.children.length === 0) {
body = <div className="schedule-card schedule-empty">No children yet.</div>;
} else {
const { data } = state;
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
const saved = data.schedules[child.id] ?? null;
const draft = drafts[child.id];
// Edits start from the saved schedule, or — with none — from "home every
// day" on a 2-week cycle anchored at the displayed month's 1st.
const schedule: PresenceScheduleBody = draft ??
saved ?? {
cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
anchor_date: month,
pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
};
const effective = draft ?? saved;
const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`;
const edit = (next: PresenceScheduleBody) => {
setDrafts((all) => ({ ...all, [child.id]: next }));
setSave({ kind: "idle" });
};
const toggle = (date: string) => {
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
};
const confirmDelete = (id: number) => {
setDeletion({ kind: "deleting", id });
deletePresenceOverride(id)
.then(() => refresh())
.then(() => setDeletion({ kind: "idle" }))
.catch((error: unknown) =>
setDeletion({
kind: "error",
id,
message: saveErrorMessage(
error,
"The override could not be deleted. Check your connection and try again.",
),
}),
);
};
const submit = () => {
if (!draft) return;
const childId = child.id;
setSave({ kind: "saving" });
const { cycle_length_weeks, anchor_date, pattern } = draft;
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
.then((stored) => {
setState((current) =>
current.kind === "ready"
? {
kind: "ready",
data: {
...current.data,
schedules: { ...current.data.schedules, [childId]: stored },
},
}
: current,
);
setDrafts((all) => {
const rest = { ...all };
delete rest[childId];
return rest;
});
setSave({ kind: "saved" });
})
.catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
};
body = (
<>
<div className="schedule-children" role="group" aria-label="Child">
{data.children.map((c) => (
<button
key={c.id}
type="button"
className="schedule-child-chip"
aria-pressed={c.id === child.id}
onClick={() => {
setSelectedId(c.id);
setSave({ kind: "idle" });
}}
>
{c.display_name}
</button>
))}
</div>
<section className="schedule-card schedule-month" aria-label="Month grid card">
<MonthGrid
month={month}
today={today}
schedule={schedule}
effective={effective}
overrides={childOverrides}
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
onToggle={toggle}
/>
<div className="schedule-controls">
<label className="schedule-field">
<span>Anchor date</span>
<input
type="date"
value={schedule.anchor_date}
onChange={(event) => {
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
}}
/>
</label>
<label className="schedule-field">
<span>Cycle length</span>
<select
value={schedule.cycle_length_weeks}
onChange={(event) => {
const weeks = Number(event.target.value);
edit({
...schedule,
cycle_length_weeks: weeks,
pattern: resizePattern(schedule.pattern, weeks),
});
}}
>
{CYCLE_LENGTH_CHOICES.map((weeks) => (
<option key={weeks} value={weeks}>
{weeks} {weeks === 1 ? "week" : "weeks"}
</option>
))}
</select>
</label>
<button
type="button"
className="schedule-save"
disabled={!draft || save.kind === "saving"}
onClick={submit}
>
{save.kind === "saving" ? "Saving…" : "Save pattern"}
</button>
</div>
{save.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{save.message}
</p>
) : save.kind === "saved" ? (
<p className="schedule-save-status" role="status">
Pattern saved.
</p>
) : null}
</section>
<h3 className="schedule-section-label">Presence pattern</h3>
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
{data.children.map((c) => (
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
<div className="schedule-row-text">
<div className="schedule-row-title">{c.display_name}</div>
</div>
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
</li>
))}
</ul>
<div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3>
<button
ref={addRef}
type="button"
className="schedule-text-action"
onClick={() => setEditorOpen(true)}
>
Add
</button>
</div>
{childOverrides.length > 0 ? (
<ul className="schedule-card schedule-list" aria-label="Overrides">
{childOverrides.map((override) => (
<OverrideRow
key={override.id}
override={override}
childName={child.display_name}
deletion={deletion}
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
onKeep={() => setDeletion({ kind: "idle" })}
onDelete={() => confirmDelete(override.id)}
/>
))}
</ul>
) : (
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
)}
{deletion.kind === "error" ? (
<p className="schedule-save-status schedule-save-status--error" role="alert">
{deletion.message}
</p>
) : null}
</>
);
}
return (
<div className="schedule-screen">
<ScreenHeader sub={sub} />
<div
className="schedule-scroll"
data-testid="schedule-scroll"
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
>
{body}
</div>
</div>
);
}
-115
View File
@@ -1,115 +0,0 @@
import { describe, expect, it } from "vitest";
import {
cycleWeekIndex,
dayState,
monthDates,
monthStart,
presenceRule,
resizePattern,
togglePatternDay,
weekdayOf,
} from "./cycle";
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
const SCHEDULE: PresenceScheduleBody = {
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
};
describe("cycleWeekIndex", () => {
it("follows the anchor across a month, wrapping every 2 weeks", () => {
const expected: Record<string, number> = {};
for (const date of monthDates("2026-09-01")) {
const day = Number(date.slice(8));
// Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
// Sep 1-6 lie before the anchor and walk back into week 1.
expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
}
for (const [date, week] of Object.entries(expected)) {
expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
}
expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
});
it("walks the cycle backwards before the anchor (floor division)", () => {
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
});
it("ignores ISO week parity across a 53-week year", () => {
// 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
// parity would call them the same week. From the anchor they alternate.
expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
});
});
describe("calendar helpers", () => {
it("weekdayOf is Monday=0", () => {
expect(weekdayOf("2026-09-07")).toBe(0);
expect(weekdayOf("2026-09-01")).toBe(1);
expect(weekdayOf("2026-09-06")).toBe(6);
});
it("monthStart shifts across year ends", () => {
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
expect(monthDates("2026-02-01")).toHaveLength(28);
});
});
describe("dayState", () => {
const overrides: PresenceOverride[] = [
{ id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
];
it("applies overrides first, then the anchor-based pattern", () => {
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
});
it("treats a child with no schedule as home every day", () => {
expect(dayState(null, [], "2026-09-15")).toBe("home");
});
});
describe("pattern editing and rule text", () => {
it("toggles one weekday of one cycle week", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
0: [0, 1, 2, 3, 4, 5, 6],
1: [1, 5, 6],
});
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
});
it("resizes the pattern to the cycle length", () => {
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it("describes the presence rule in plain language", () => {
expect(presenceRule(null)).toBe("Every day");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
"Every day",
);
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
expect(
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
).toBe("Weeks 1 & 3 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
});
});
-142
View File
@@ -1,142 +0,0 @@
/**
* Pure presence-cycle math for the Schedule tab.
*
* Cycle position comes from the anchor date ONLY, mirroring
* core/presence.py `cycle_week_index`:
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
* so dates before the anchor walk the cycle backwards. Never ISO week
* numbers or week parity (D-004).
*
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
* viewer offset or DST change can shift a day.
*/
import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence";
const MS_PER_DAY = 86_400_000;
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
export function dayNumber(iso: string): number {
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 Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
}
export function isoFromDayNumber(days: number): string {
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
}
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
function mod(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor;
}
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
export function weekdayOf(iso: string): number {
return mod(dayNumber(iso) + 3, 7);
}
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
const days = dayNumber(date) - dayNumber(anchorDate);
return mod(Math.floor(days / 7), cycleLengthWeeks);
}
/** The viewer's local calendar date. */
export function localTodayIso(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
export function monthStart(iso: string, delta = 0): string {
const [year, month] = iso.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return date.toISOString().slice(0, 10);
}
/** Every date of the month starting at `firstIso`. */
export function monthDates(firstIso: string): string[] {
const start = dayNumber(firstIso);
const end = dayNumber(monthStart(firstIso, 1));
const dates: string[] = [];
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
return dates;
}
/** Present every day for every week of the cycle (the no-schedule case). */
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
const pattern: PresencePattern = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
return pattern;
}
export type DayState = "home" | "away" | "override-home" | "override-away";
export function patternIncludes(
schedule: PresenceScheduleBody,
date: string,
): boolean {
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
}
/**
* A date's state for one child: an override covering the date wins;
* otherwise the schedule's pattern decides (no schedule = home every day).
*/
export function dayState(
schedule: PresenceScheduleBody | null,
overrides: PresenceOverride[],
date: string,
): DayState {
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
if (override) return override.is_present ? "override-home" : "override-away";
if (!schedule) return "home";
return patternIncludes(schedule, date) ? "home" : "away";
}
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
export function togglePatternDay(
pattern: PresencePattern,
week: number,
weekday: number,
): PresencePattern {
const days = pattern[week] ?? [];
const next = days.includes(weekday)
? days.filter((d) => d !== weekday)
: [...days, weekday].sort((a, b) => a - b);
return { ...pattern, [week]: next };
}
/** Fit the pattern to a new cycle length; added weeks start present every day. */
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
const next: PresencePattern = {};
for (let week = 0; week < cycleLengthWeeks; week += 1) {
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
}
return next;
}
function joinWeeks(weeks: number[]): string {
if (weeks.length === 1) return String(weeks[0]);
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
}
/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */
export function presenceRule(schedule: PresenceScheduleBody | null): string {
if (!schedule) return "Every day";
const weeks: number[] = [];
let full = true;
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
const days = schedule.pattern[week] ?? [];
if (days.length > 0) weeks.push(week + 1);
if (days.length < 7) full = false;
}
if (full) return "Every day";
if (weeks.length === 0) return "Never home";
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
}
-37
View File
@@ -1,37 +0,0 @@
/**
* Date labels for the Schedule tab. Calendar dates are formatted in UTC from
* their components, so the printed day never shifts with the viewer's offset.
*/
import { dayNumber } from "./cycle";
const LOCALE = "en-US";
function utcDate(iso: string): Date {
return new Date(dayNumber(iso) * 86_400_000);
}
function format(iso: string, options: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
}
/** "Tuesday, Sep 1" */
export function anchorLabel(iso: string): string {
return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
}
/** "September 2026" */
export function monthLabel(iso: string): string {
return format(iso, { month: "long", year: "numeric" });
}
/** "Sep 4" */
export function shortDate(iso: string): string {
return format(iso, { month: "short", day: "numeric" });
}
/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
export function dateRange(start: string, end: string): string {
if (start === end) return shortDate(start);
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
* CDN-free like the Today and Tasks tabs' glyphs.
*/
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 ChevronLeftGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m15 18-6-6 6-6" />
</Glyph>
);
}
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="m9 18 6-6-6-6" />
</Glyph>
);
}
function CalendarFrame() {
return (
<>
<path d="M8 2v4M16 2v4" />
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M3 10h18" />
</>
);
}
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m14 14-4 4M10 14l4 4" />
</Glyph>
);
}
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
return (
<Glyph size={size}>
<CalendarFrame />
<path d="m9 16 2 2 4-4" />
</Glyph>
);
}
export function Trash2Glyph({ size = 16 }: { size?: number }) {
return (
<Glyph size={size}>
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
</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>
);
}