From 39417615a267e39f3680770b3810e35f79353781 Mon Sep 17 00:00:00 2001 From: Joshua Date: Thu, 24 Sep 2026 19:45:53 -0400 Subject: [PATCH] Move the Admin PWA Schedule tab to multi-pattern presence The admin client still called the removed one-per-child /presence-schedule route, so the Schedule tab could not load or save (review finding F1 on PR #231). The client now uses the presence-pattern routes: list and create under /children/{id}/presence-patterns, and PATCH / DELETE on /presence-patterns/{id}. The Schedule screen lists every pattern of the selected child (name, Home/Away, cycle length, weekday set) with Add, Edit and Delete, and a pushed pattern editor sets name, kind, cycle length, anchor date and the per-week weekday set. Edits send only the changed fields. The month grid shows the combined presence with the core's first-match rule: override, then any covering away pattern, then any covering home pattern, then away when the child has a home pattern, else home. The grid keeps the Sunday-first week; stored weekdays stay Monday=0. Co-Authored-By: Claude Opus 5.5 --- admin/src/api/presence.ts | 96 ++-- admin/src/schedule/OverrideEditor.css | 9 + admin/src/schedule/OverrideEditor.test.tsx | 21 +- admin/src/schedule/OverrideEditor.tsx | 35 +- admin/src/schedule/PatternEditor.tsx | 256 +++++++++++ admin/src/schedule/ScheduleTab.css | 75 +--- admin/src/schedule/ScheduleTab.test.tsx | 485 ++++++++++++++++----- admin/src/schedule/ScheduleTab.tsx | 358 ++++++++------- admin/src/schedule/cycle.test.ts | 114 +++-- admin/src/schedule/cycle.ts | 95 ++-- admin/src/schedule/glyphs.tsx | 9 + 11 files changed, 1098 insertions(+), 455 deletions(-) create mode 100644 admin/src/schedule/PatternEditor.tsx diff --git a/admin/src/api/presence.ts b/admin/src/api/presence.ts index a04461e..ad6816a 100644 --- a/admin/src/api/presence.ts +++ b/admin/src/api/presence.ts @@ -1,25 +1,35 @@ /** - * 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). + * Typed client for the admin presence routes: a child's repeating patterns + * (/api/v1/admin/children/{id}/presence-patterns, /presence-patterns/{id}) + * 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; +/** A pattern claims its covered days as the child being home or away. */ +export type PatternKind = "home" | "away"; -export interface PresenceScheduleBody { +/** + * Cycle week index ("0".."n-1", JSON object keys) -> covered weekdays + * (0 = Monday .. 6 = Sunday). An empty list covers nothing that week. + */ +export type PatternWeeks = Record; + +/** The anchor-based cycle every pattern repeats on. */ +export interface PatternCycle { cycle_length_weeks: number; /** "YYYY-MM-DD"; pins cycle week 0. */ anchor_date: string; - pattern: PresencePattern; + pattern: PatternWeeks; } -export interface PresenceSchedule extends PresenceScheduleBody { +export interface PresencePatternBody extends PatternCycle { + name: string; + kind: PatternKind; +} + +export interface PresencePattern extends PresencePatternBody { + id: number; child_id: number; } @@ -40,30 +50,48 @@ export interface PresenceOverrideFilter { } export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides"; +export const PRESENCE_PATTERNS_PATH = "/api/v1/admin/presence-patterns"; -export function presenceSchedulePath(childId: number): string { - return `${CHILDREN_PATH}/${childId}/presence-schedule`; +export function childPatternsPath(childId: number): string { + return `${CHILDREN_PATH}/${childId}/presence-patterns`; } -/** The child's schedule, or null when it has none (present every day). */ -export async function fetchPresenceSchedule(childId: number): Promise { - const body = await readJson<{ schedule: PresenceSchedule | null }>( - await apiFetch(presenceSchedulePath(childId)), +function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise { + return apiFetch(path, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The child's patterns in id order; empty when it has none (present every day). */ +export async function fetchPresencePatterns(childId: number): Promise { + const body = await readJson<{ patterns: PresencePattern[] }>( + await apiFetch(childPatternsPath(childId)), ); - return body.schedule; + return body.patterns; } -/** Set (upsert) the child's schedule; resolves to the schedule as stored. */ -export async function savePresenceSchedule( +export async function createPresencePattern( childId: number, - body: PresenceScheduleBody, -): Promise { - return readJson( - await apiFetch(presenceSchedulePath(childId), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), + body: PresencePatternBody, +): Promise { + return readJson(await sendJson(childPatternsPath(childId), "POST", body)); +} + +/** Change only the supplied fields; resolves to the pattern as stored. */ +export async function updatePresencePattern( + patternId: number, + body: Partial, +): Promise { + return readJson( + await sendJson(`${PRESENCE_PATTERNS_PATH}/${patternId}`, "PATCH", body), + ); +} + +export async function deletePresencePattern(patternId: number): Promise { + await readJson<{ status: string }>( + await apiFetch(`${PRESENCE_PATTERNS_PATH}/${patternId}`, { method: "DELETE" }), ); } @@ -96,27 +124,19 @@ export interface PresenceOverrideBody extends PresenceOverrideRange { export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`; -function postJson(path: string, body: unknown): Promise { - 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 { const body = await readJson<{ removed: number }>( - await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range), + await sendJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, "POST", range), ); return body.removed; } export async function createPresenceOverride(body: PresenceOverrideBody): Promise { - return readJson(await postJson(PRESENCE_OVERRIDES_PATH, body)); + return readJson(await sendJson(PRESENCE_OVERRIDES_PATH, "POST", body)); } export async function deletePresenceOverride(overrideId: number): Promise { diff --git a/admin/src/schedule/OverrideEditor.css b/admin/src/schedule/OverrideEditor.css index e95c6e4..87a7d5d 100644 --- a/admin/src/schedule/OverrideEditor.css +++ b/admin/src/schedule/OverrideEditor.css @@ -95,6 +95,15 @@ grid-template-columns: repeat(2, 1fr); } +.override-segments--7 { + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.override-segments--7 .override-segment { + padding: 0; +} + .override-segment { min-height: var(--nq-a-tap-min); padding: 0 8px; diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx index 0a2c4bb..c57c5d5 100644 --- a/admin/src/schedule/OverrideEditor.test.tsx +++ b/admin/src/schedule/OverrideEditor.test.tsx @@ -4,7 +4,7 @@ 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 type { PresenceOverride, PresencePattern } from "../api/presence"; import { storeTokens } from "../auth/oidc"; function child(id: number, display_name: string, sort_order: number): AdminChild { @@ -13,8 +13,11 @@ function child(id: number, display_name: string, sort_order: number): AdminChild const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)]; -const DECLAN: PresenceSchedule = { +const DECLAN: PresencePattern = { + id: 1, child_id: 1, + name: "Home weeks", + kind: "home", cycle_length_weeks: 2, anchor_date: "2026-09-07", pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] }, @@ -72,9 +75,9 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise { }); await waitFor(() => expect(saveButton().disabled).toBe(false)); const before = calls().length; - const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length; + const patternsBefore = calls().filter((c) => c.path.endsWith("/presence-patterns")).length; fireEvent.click(saveButton()); const row = await screen.findByTestId("override-50"); @@ -321,10 +324,10 @@ describe("OverrideEditor", () => { }, ]); expect(after.some((c) => c.method === "DELETE")).toBe(false); - // The overrides and the presence schedules are refetched for the grid. + // The overrides and the presence patterns 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, + expect(calls().filter((c) => c.path.endsWith("/presence-patterns")).length).toBeGreaterThan( + patternsBefore, ); // The grid reflects the new override. expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe( diff --git a/admin/src/schedule/OverrideEditor.tsx b/admin/src/schedule/OverrideEditor.tsx index 3a6ca4a..2205821 100644 --- a/admin/src/schedule/OverrideEditor.tsx +++ b/admin/src/schedule/OverrideEditor.tsx @@ -39,7 +39,7 @@ type Consequence = | { kind: "ready"; removed: number } | { kind: "error"; message: string }; -function errorMessage(error: unknown, fallback: string): string { +export 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; @@ -71,6 +71,24 @@ export function inertOutside(root: HTMLElement): () => void { }; } +/** Keep Tab / Shift+Tab cycling inside `root` (a pushed modal screen). */ +export function trapTab(event: ReactKeyboardEvent, root: HTMLElement | null): void { + if (event.key !== "Tab" || !root) return; + const focusable = Array.from(root.querySelectorAll(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(); + } +} + function tasksPhrase(count: number): string { return `${count} upcoming ${count === 1 ? "task" : "tasks"}`; } @@ -118,20 +136,7 @@ export default function OverrideEditor({ if (!saving) onCancel(); return; } - if (event.key !== "Tab" || !screenRef.current) return; - const focusable = Array.from(screenRef.current.querySelectorAll(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(); - } + trapTab(event, screenRef.current); } const child = childOptions.find((c) => c.id === childId) ?? childOptions[0]; diff --git a/admin/src/schedule/PatternEditor.tsx b/admin/src/schedule/PatternEditor.tsx new file mode 100644 index 0000000..1e61fae --- /dev/null +++ b/admin/src/schedule/PatternEditor.tsx @@ -0,0 +1,256 @@ +/** + * Presence pattern editor — a pushed screen like the override editor. + * + * Adds a pattern to the child's collection (POST) or edits one in place + * (PATCH with only the changed fields). The weekday numbering sent to the + * API stays Monday=0 .. Sunday=6; only the display is Sunday-first. + */ +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; +import type { AdminChild } from "../api/definitions"; +import { + createPresencePattern, + updatePresencePattern, + type PatternKind, + type PatternWeeks, + type PresencePattern, + type PresencePatternBody, +} from "../api/presence"; +import { + DISPLAY_WEEKDAYS, + WEEKDAY_SHORT, + dayNumber, + emptyPattern, + isoFromDayNumber, + resizePattern, + togglePatternDay, +} from "./cycle"; +import { anchorLabel, shortDate } from "./format"; +import { ChevronLeftGlyph } from "./glyphs"; +import { errorMessage, inertOutside, isIsoDate, trapTab } from "./OverrideEditor"; +import "./OverrideEditor.css"; + +export const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4]; + +const WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +/** The fields of `next` that differ from the stored pattern. */ +export function changedFields( + stored: PresencePatternBody, + next: PresencePatternBody, +): Partial { + const changes: Partial = {}; + if (next.name !== stored.name) changes.name = next.name; + if (next.kind !== stored.kind) changes.kind = next.kind; + if (next.cycle_length_weeks !== stored.cycle_length_weeks) { + changes.cycle_length_weeks = next.cycle_length_weeks; + } + if (next.anchor_date !== stored.anchor_date) changes.anchor_date = next.anchor_date; + const samePattern = Array.from({ length: next.cycle_length_weeks }, (_, week) => week).every( + (week) => + JSON.stringify([...(stored.pattern[week] ?? [])].sort()) === + JSON.stringify([...(next.pattern[week] ?? [])].sort()), + ); + if (!samePattern || changes.cycle_length_weeks !== undefined) changes.pattern = next.pattern; + return changes; +} + +export interface PatternEditorProps { + child: AdminChild; + /** The pattern being edited, or null to add a new one. */ + pattern: PresencePattern | null; + /** "YYYY-MM-DD"; a new pattern's anchor starts here. */ + today: string; + onCancel: () => void; + onSaved: (pattern: PresencePattern) => void; +} + +export default function PatternEditor({ child, pattern, today, onCancel, onSaved }: PatternEditorProps) { + const [name, setName] = useState(pattern?.name ?? ""); + const [kind, setKind] = useState(pattern?.kind ?? "away"); + const [cycleLength, setCycleLength] = useState(pattern?.cycle_length_weeks ?? 1); + const [anchor, setAnchor] = useState(pattern?.anchor_date ?? today); + const [weeks, setWeeks] = useState(pattern?.pattern ?? emptyPattern(1)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const screenRef = useRef(null); + const titleRef = useRef(null); + + useEffect(() => { + titleRef.current?.focus(); + return screenRef.current ? inertOutside(screenRef.current) : undefined; + }, []); + + function onKeyDown(event: ReactKeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + if (!saving) onCancel(); + return; + } + trapTab(event, screenRef.current); + } + + const body: PresencePatternBody = { + name: name.trim(), + kind, + cycle_length_weeks: cycleLength, + anchor_date: anchor, + pattern: resizePattern(weeks, cycleLength), + }; + const changes = pattern ? changedFields(pattern, body) : body; + const coversADay = Object.values(body.pattern).some((days) => days.length > 0); + const canSave = + body.name !== "" && isIsoDate(anchor) && coversADay && Object.keys(changes).length > 0 && !saving; + + function save() { + if (!canSave) return; + setSaving(true); + setError(null); + const request = pattern + ? updatePresencePattern(pattern.id, changes) + : createPresencePattern(child.id, body); + request.then(onSaved).catch((err: unknown) => { + setError(errorMessage(err, "The pattern could not be saved. Check your connection and try again.")); + setSaving(false); + }); + } + + const title = pattern ? "Edit pattern" : "New pattern"; + return ( +
+
+ +
+

+ {title} +

+

Repeating presence for {child.display_name}

+
+
+ +
+
+ + +
+ + Covered days are + +
+ {(["away", "home"] as PatternKind[]).map((value) => ( + + ))} +
+
+ +
+ + +
+ + {Array.from({ length: cycleLength }, (_, week) => { + const days = body.pattern[week]; + const from = isIsoDate(anchor) + ? shortDate(isoFromDayNumber(dayNumber(anchor) + week * 7)) + : null; + return ( +
+ + Week {week + 1} + {from ? ` · from ${from}` : ""} + +
+ {DISPLAY_WEEKDAYS.map((weekday) => ( + + ))} +
+
+ ); + })} +

+ {isIsoDate(anchor) + ? `Week 1 starts ${anchorLabel(anchor)}; the cycle repeats from there.` + : "Choose an anchor date."} +

+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/admin/src/schedule/ScheduleTab.css b/admin/src/schedule/ScheduleTab.css index 60540b5..66ded62 100644 --- a/admin/src/schedule/ScheduleTab.css +++ b/admin/src/schedule/ScheduleTab.css @@ -119,14 +119,13 @@ } .schedule-day { + display: grid; + place-items: center; 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 { @@ -184,60 +183,11 @@ 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; +.schedule-legend-note { + margin: 8px 0 0; + font-size: 10.5px; 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; + color: var(--nq-a-ink-tertiary); } .schedule-save-status { @@ -251,7 +201,7 @@ color: var(--nq-a-danger-strong); } -/* Presence pattern + overrides cards */ +/* Presence patterns + overrides cards */ .schedule-section-head { display: flex; align-items: center; @@ -318,13 +268,6 @@ 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; @@ -339,6 +282,10 @@ cursor: pointer; } +.schedule-icon-action--edit { + margin-right: -4px; +} + .schedule-empty { padding: var(--nq-a-density-card-pad); font-size: 13px; diff --git a/admin/src/schedule/ScheduleTab.test.tsx b/admin/src/schedule/ScheduleTab.test.tsx index 24af4d0..cad9ed1 100644 --- a/admin/src/schedule/ScheduleTab.test.tsx +++ b/admin/src/schedule/ScheduleTab.test.tsx @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import ScheduleTab from "./ScheduleTab"; import type { AdminChild } from "../api/definitions"; -import type { PresenceOverride, PresenceSchedule } from "../api/presence"; +import type { PresenceOverride, PresencePattern } from "../api/presence"; import { storeTokens } from "../auth/oidc"; +import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle"; 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 }; @@ -16,20 +17,39 @@ const CHILDREN = [ 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 = { +// Declan: the migrated single schedule — one `home` pattern anchored Monday +// 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun only. Keys arrive +// as JSON strings, exactly as the API serializes them. +const DECLAN_HOME: PresencePattern = { + id: 1, child_id: 1, + name: "Home weeks", + kind: "home", cycle_length_weeks: 2, anchor_date: "2026-09-07", pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] }, }; -const MAEVE: PresenceSchedule = { +// Maeve: the owner scenario — away Thursday+Friday every week, and away +// weekends every 2 weeks (the "on" week is cycle week 0 from Sep 7). +const MAEVE_THU_FRI: PresencePattern = { + id: 2, child_id: 2, - cycle_length_weeks: 4, + name: "Thursday+Friday", + kind: "away", + cycle_length_weeks: 1, anchor_date: "2026-09-07", - pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] }, + pattern: { "0": [3, 4] }, +}; + +const MAEVE_WEEKENDS: PresencePattern = { + id: 3, + child_id: 2, + name: "Alternate weekends", + kind: "away", + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { "0": [5, 6], "1": [] }, }; const OVERRIDES: PresenceOverride[] = [ @@ -39,6 +59,7 @@ const OVERRIDES: PresenceOverride[] = [ ]; const TODAY = "2026-09-23"; +const PATTERNS_PATH = "/api/v1/admin/presence-patterns"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -47,17 +68,69 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +interface Call { + method: string; + path: string; + body: unknown; +} + +/** The server's pattern store; POST/PATCH/DELETE mutate it like the API. */ +let patterns: Record; +let nextId: number; +let writeFailure: Response | null; let fetchMock: ReturnType; -let putResponse: (body: unknown) => Response; + +function calls(): Call[] { + return fetchMock.mock.calls.map((args: unknown[]) => { + const [url, init] = args as [string, RequestInit | undefined]; + return { + method: init?.method ?? "GET", + path: new URL(String(url), "http://x").pathname, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + }); +} + +function writes(): Call[] { + return calls().filter((c) => c.method !== "GET"); +} + +function findPattern(id: number): PresencePattern | undefined { + return Object.values(patterns) + .flat() + .find((p) => p.id === id); +} 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))); + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(String(init.body)) : undefined; + if (method !== "GET" && writeFailure) return writeFailure; 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 }); + const collection = /\/children\/(\d+)\/presence-patterns$/.exec(path); + if (collection) { + const childId = Number(collection[1]); + if (method === "POST") { + const stored: PresencePattern = { id: nextId++, child_id: childId, ...body }; + patterns[childId] = [...(patterns[childId] ?? []), stored]; + return jsonResponse(stored, 201); + } + return jsonResponse({ patterns: patterns[childId] ?? [] }); + } + const single = /\/presence-patterns\/(\d+)$/.exec(path); + if (single) { + const id = Number(single[1]); + const existing = findPattern(id); + if (!existing) return jsonResponse({ detail: "Presence pattern not found" }, 404); + if (method === "PATCH") { + const updated = { ...existing, ...body }; + patterns[existing.child_id] = patterns[existing.child_id].map((p) => (p.id === id ? updated : p)); + return jsonResponse(updated); + } + if (method === "DELETE") { + patterns[existing.child_id] = patterns[existing.child_id].filter((p) => p.id !== id); + return jsonResponse({ status: "ok" }); + } } if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES }); return jsonResponse({ detail: "unexpected" }, 500); @@ -69,16 +142,32 @@ function day(date: string): HTMLElement { return cell; } +function saveButton(): HTMLButtonElement { + return screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement; +} + +function weekGroup(week: number): HTMLElement { + return screen.getByRole("group", { name: new RegExp(`^Week ${week}\\b`) }); +} + async function renderReady() { render(); await screen.findByTestId("pattern-1"); } +async function selectMaeve() { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Maeve" })); + await screen.findByTestId("pattern-3"); +} + describe("ScheduleTab", () => { beforeEach(() => { sessionStorage.clear(); storeTokens({ access_token: "at-123", expires_in: 600 }); - putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) }); + patterns = { 1: [DECLAN_HOME], 2: [MAEVE_THU_FRI, MAEVE_WEEKENDS], 3: [] }; + nextId = 50; + writeFailure = null; fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); vi.stubGlobal("fetch", fetchMock); }); @@ -88,27 +177,31 @@ describe("ScheduleTab", () => { vi.unstubAllGlobals(); }); - it("loads active children, their schedules and the overrides", async () => { + it("loads active children, their patterns and the overrides", async () => { await renderReady(); - const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname); + const urls = calls().map((c) => c.path); 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/children/1/presence-patterns"); + expect(urls).toContain("/api/v1/admin/children/2/presence-patterns"); + expect(urls).toContain("/api/v1/admin/children/3/presence-patterns"); + expect(urls).not.toContain("/api/v1/admin/children/9/presence-patterns"); 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 () => { + it("header names the child and how many patterns it has", async () => { await renderReady(); expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy(); - expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy(); + expect(screen.getByText("Declan · 1 pattern")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Maeve" })); + expect(screen.getByText("Maeve · 2 patterns")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Rory" })); + expect(screen.getByText("Rory · no patterns, home every day")).toBeTruthy(); }); - it("month grid renders home, away, override and today states from the anchor", async () => { + it("a home-pattern-only child is away on days no home pattern covers", async () => { await renderReady(); expect(screen.getByTestId("month-label").textContent).toBe("September 2026"); const states: Record = { @@ -131,6 +224,8 @@ describe("ScheduleTab", () => { expect(today.className).toContain("schedule-day--today"); expect(today.dataset.state).toBe("home"); expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home"); + // The grid is a read-out of the combined presence, not a set of toggles. + expect(today.tagName).toBe("SPAN"); // Sunday-first header; September 2026 starts on a Tuesday: two leading pads. const grid = screen.getByRole("group", { name: "Month grid" }); expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([ @@ -142,8 +237,8 @@ describe("ScheduleTab", () => { "F", "S", ]); - expect(grid.children[7].tagName).toBe("SPAN"); - expect(grid.children[8].tagName).toBe("SPAN"); + expect(grid.children[7].getAttribute("data-date")).toBeNull(); + expect(grid.children[8].getAttribute("data-date")).toBeNull(); expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01"); const legend = screen.getByRole("list", { name: "Legend" }); @@ -154,6 +249,29 @@ describe("ScheduleTab", () => { "Override · home", "Today", ]); + expect(screen.getByText(/Combined from every pattern/)).toBeTruthy(); + }); + + it("the owner scenario: Thu/Fri away every week, Sat/Sun away only in the on week", async () => { + await selectMaeve(); + for (const date of monthDates("2026-09-01")) { + if (date >= "2026-09-28") continue; // Camp override, checked below + const weekday = weekdayOf(date); + const onWeek = cycleWeekIndex("2026-09-07", date, 2) === 0; + const away = weekday === 3 || weekday === 4 || (weekday >= 5 && onWeek); + expect(day(date).dataset.state, date).toBe(away ? "away" : "home"); + } + // Explicit spot checks of that rule. + expect(day("2026-09-03").dataset.state).toBe("away"); // Thu + expect(day("2026-09-04").dataset.state).toBe("away"); // Fri + expect(day("2026-09-05").dataset.state).toBe("home"); // Sat, off week (pre-anchor) + expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week + expect(day("2026-09-13").dataset.state).toBe("away"); // Sun, on week + expect(day("2026-09-19").dataset.state).toBe("home"); // Sat, off week + expect(day("2026-09-20").dataset.state).toBe("home"); // Sun, off week + expect(day("2026-09-26").dataset.state).toBe("away"); // Sat, on week again + expect(day("2026-09-14").dataset.state).toBe("home"); // Mon + expect(day("2026-09-28").dataset.state).toBe("override-away"); }); it("navigates months with the chevrons", async () => { @@ -168,14 +286,36 @@ describe("ScheduleTab", () => { 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("patterns list shows each pattern's name, kind, cycle and weekday set", async () => { + await selectMaeve(); + const list = screen.getByRole("list", { name: "Presence patterns" }); + expect(within(list).getAllByRole("listitem")).toHaveLength(2); + const thuFri = within(list).getByTestId("pattern-2"); + expect(within(thuFri).getByText("Thursday+Friday")).toBeTruthy(); + expect(within(thuFri).getByText("Away · Every week")).toBeTruthy(); + expect(within(thuFri).getByText("Thu, Fri")).toBeTruthy(); + const weekends = within(list).getByTestId("pattern-3"); + expect(within(weekends).getByText("Alternate weekends")).toBeTruthy(); + expect(within(weekends).getByText("Away · Every 2 weeks")).toBeTruthy(); + expect(within(weekends).getByText("Week 1: Sat, Sun · Week 2: No days")).toBeTruthy(); + expect(within(weekends).getByRole("button", { name: "Edit Alternate weekends" })).toBeTruthy(); + expect(within(weekends).getByRole("button", { name: "Delete Alternate weekends" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Declan" })); + const declan = screen.getByTestId("pattern-1"); + expect(within(declan).getByText("Home · Every 2 weeks")).toBeTruthy(); + expect(within(declan).getByText("Week 1: Every day · Week 2: Sat, Sun")).toBeTruthy(); }); - it("overrides card lists the selected child's overrides with inert actions", async () => { + it("a child with no patterns is home every day", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Rory" })); + expect(screen.getByTestId("patterns-empty").textContent).toBe("No patterns for Rory — home every day."); + for (const date of monthDates("2026-09-01")) expect(day(date).dataset.state, date).toBe("home"); + expect(screen.getByText("No overrides for Rory.")).toBeTruthy(); + }); + + it("overrides card lists the selected child's overrides", async () => { await renderReady(); const list = screen.getByRole("list", { name: "Overrides" }); expect(within(list).getAllByRole("listitem")).toHaveLength(2); @@ -188,86 +328,233 @@ describe("ScheduleTab", () => { 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 () => { + it("Add POSTs a new pattern to the child's collection and refreshes the list from the API", 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, - ); - }); + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + expect(await screen.findByRole("heading", { name: "New pattern" })).toBeTruthy(); + // Nothing named and no day covered yet. + expect(saveButton().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 + fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Dad's weekends " } }); + fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } }); + fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-07" } }); + // Sunday-first buttons; the body keeps Monday=0 numbering. + const week1 = weekGroup(1); + expect(within(week1).getAllByRole("button").map((b) => b.textContent)).toEqual([ + "Su", + "Mo", + "Tu", + "We", + "Th", + "Fr", + "Sa", + ]); + fireEvent.click(within(week1).getByRole("button", { name: "Saturday" })); + fireEvent.click(within(week1).getByRole("button", { name: "Sunday" })); + expect(saveButton().disabled).toBe(false); - // 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"); + const before = calls().length; + fireEvent.click(saveButton()); + const row = await screen.findByTestId("pattern-50"); + expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull(); - 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], + expect(writes()).toEqual([ + { + method: "POST", + path: "/api/v1/admin/children/3/presence-patterns", + body: { + name: "Dad's weekends", + kind: "away", + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { "0": [5, 6], "1": [] }, + }, }, - }); + ]); + const after = calls().slice(before); + expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/3/presence-patterns")).toBe( + true, + ); + expect(within(row).getByText("Dad's weekends")).toBeTruthy(); + expect(within(row).getByText("Away · Every 2 weeks")).toBeTruthy(); + expect(day("2026-09-12").dataset.state).toBe("away"); + expect(day("2026-09-19").dataset.state).toBe("home"); + // Focus returns to the Add control of the patterns section. + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" })); }); - it("a 422 shows the API detail and keeps the draft", async () => { - putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422); + it("Add can save a Home pattern", async () => { await renderReady(); - fireEvent.click(day("2026-09-01")); - fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); + fireEvent.click(screen.getByRole("button", { name: "Rory" })); + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + await screen.findByRole("heading", { name: "New pattern" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "School nights" } }); + fireEvent.click(screen.getByRole("button", { name: "Home" })); + fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" })); + fireEvent.click(saveButton()); + await screen.findByTestId("pattern-50"); + expect(writes()[0].body).toEqual({ + name: "School nights", + kind: "home", + cycle_length_weeks: 1, + anchor_date: TODAY, + pattern: { "0": [0] }, + }); + // A home pattern: uncovered days are now away. + expect(day("2026-09-14").dataset.state).toBe("home"); // Monday + expect(day("2026-09-15").dataset.state).toBe("away"); // Tuesday + }); - 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, + it("Edit PATCHes only the changed fields to /presence-patterns/{id} and refreshes", async () => { + await selectMaeve(); + fireEvent.click(screen.getByRole("button", { name: "Edit Alternate weekends" })); + expect(await screen.findByRole("heading", { name: "Edit pattern" })).toBeTruthy(); + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Alternate weekends"); + expect((screen.getByLabelText("Cycle length") as HTMLSelectElement).value).toBe("2"); + expect((screen.getByLabelText("Anchor date") as HTMLInputElement).value).toBe("2026-09-07"); + expect(within(weekGroup(1)).getByRole("button", { name: "Saturday" }).getAttribute("aria-pressed")).toBe( + "true", ); - // The saved rule is unchanged. - expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy(); + // Unchanged: nothing to save. + expect(saveButton().disabled).toBe(true); + + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Every other weekend" } }); + fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Sunday" })); + const before = calls().length; + fireEvent.click(saveButton()); + await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull()); + + expect(writes()).toEqual([ + { + method: "PATCH", + path: `${PATTERNS_PATH}/3`, + body: { name: "Every other weekend", pattern: { "0": [5], "1": [] } }, + }, + ]); + const after = calls().slice(before); + expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/2/presence-patterns")).toBe( + true, + ); + const row = await screen.findByText("Every other weekend"); + expect(within(row.closest("li")!).getByText("Week 1: Sat · Week 2: No days")).toBeTruthy(); + expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week + expect(day("2026-09-13").dataset.state).toBe("home"); // Sun no longer covered + }); + + it("Delete asks first, DELETEs /presence-patterns/{id} and refreshes the list", async () => { + await selectMaeve(); + fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" })); + const confirm = within(screen.getByTestId("pattern-2")).getByRole("group", { name: "Confirm delete" }); + fireEvent.click(within(confirm).getByRole("button", { name: "Keep" })); + expect(writes()).toEqual([]); + + fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" })); + fireEvent.click(within(screen.getByTestId("pattern-2")).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(screen.queryByTestId("pattern-2")).toBeNull()); + expect(writes()).toEqual([{ method: "DELETE", path: `${PATTERNS_PATH}/2`, body: undefined }]); + expect(screen.getByTestId("pattern-3")).toBeTruthy(); + expect(day("2026-09-17").dataset.state).toBe("home"); // Thursday, no longer away + expect(day("2026-09-12").dataset.state).toBe("away"); // weekends pattern still applies + }); + + it("deleting the last pattern leaves the child with none: home every day", async () => { + await renderReady(); + expect(day("2026-09-15").dataset.state).toBe("away"); + fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" })); + fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" })); + expect(await screen.findByTestId("patterns-empty")).toBeTruthy(); + expect(screen.queryByRole("list", { name: "Presence patterns" })).toBeNull(); + expect(patterns[1]).toEqual([]); + expect(day("2026-09-15").dataset.state).toBe("home"); + expect(day("2026-09-11").dataset.state).toBe("override-away"); // overrides still win + expect(screen.getByText("Declan · no patterns, home every day")).toBeTruthy(); + }); + + it("a 422 on save shows the API detail naming the field and keeps the editor open", async () => { + writeFailure = jsonResponse({ detail: "name must not be empty" }, 422); + await selectMaeve(); + fireEvent.click(screen.getByRole("button", { name: "Edit Thursday+Friday" })); + await screen.findByRole("heading", { name: "Edit pattern" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Thu/Fri" } }); + fireEvent.click(saveButton()); + + const alert = await screen.findByTestId("pattern-error"); + expect(alert.getAttribute("role")).toBe("alert"); + expect(alert.textContent).toBe("name must not be empty"); + expect(screen.getByRole("heading", { name: "Edit pattern" })).toBeTruthy(); + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Thu/Fri"); + expect(saveButton().disabled).toBe(false); + }); + + it("a 403 on add is surfaced as the refusal, not swallowed", async () => { + writeFailure = new Response("", { status: 403 }); + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + await screen.findByRole("heading", { name: "New pattern" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Fridays" } }); + fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Friday" })); + fireEvent.click(saveButton()); + const alert = await screen.findByTestId("pattern-error"); + expect(alert.textContent).toBe("Your account is not in the nestquest-admins group."); + expect(patterns[1]).toEqual([DECLAN_HOME]); + }); + + it("a 403 on delete is surfaced and the pattern stays listed", async () => { + writeFailure = new Response("", { status: 403 }); + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" })); + fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toBe("Your account is not in the nestquest-admins group."); + expect(screen.getByTestId("pattern-1")).toBeTruthy(); + }); + + it("a 422 on delete shows the API detail", async () => { + writeFailure = jsonResponse({ detail: "pattern_id must be an integer" }, 422); + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" })); + fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" })); + expect((await screen.findByRole("alert")).textContent).toBe("pattern_id must be an integer"); + }); + + it("Cancel closes the editor without any request", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + await screen.findByRole("heading", { name: "New pattern" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Draft" } }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull(); + expect(writes()).toEqual([]); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" })); + }); + + it("never calls the removed one-per-child /presence-schedule route", async () => { + await selectMaeve(); + // Add, edit and delete: every pattern path the screen can take. + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + await screen.findByRole("heading", { name: "New pattern" }); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } }); + fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" })); + fireEvent.click(saveButton()); + await screen.findByTestId("pattern-50"); + fireEvent.click(screen.getByRole("button", { name: "Edit Mondays" })); + await screen.findByRole("heading", { name: "Edit pattern" }); + fireEvent.click(screen.getByRole("button", { name: "Home" })); + fireEvent.click(saveButton()); + await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull()); + fireEvent.click(await screen.findByRole("button", { name: "Delete Mondays" })); + fireEvent.click(within(screen.getByTestId("pattern-50")).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(screen.queryByTestId("pattern-50")).toBeNull()); + + expect(writes().map((c) => `${c.method} ${c.path}`)).toEqual([ + "POST /api/v1/admin/children/2/presence-patterns", + `PATCH ${PATTERNS_PATH}/50`, + `DELETE ${PATTERNS_PATH}/50`, + ]); + expect(calls().filter((c) => c.path.includes("presence-schedule"))).toEqual([]); }); it("scroll column ends with 92px bottom padding", async () => { diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index 3ab4384..06074ac 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -1,51 +1,48 @@ /** - * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4). + * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4) and the + * selected child's presence patterns and overrides. * - * Every day's cycle position comes from the anchor date (./cycle.ts), never - * from ISO week numbers or week parity (D-004). + * The grid shows the COMBINED presence of every pattern (./cycle.ts + * `dayState`); each pattern's cycle position comes from its own anchor date, + * 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, + deletePresencePattern, fetchPresenceOverrides, - fetchPresenceSchedule, - savePresenceSchedule, + fetchPresencePatterns, type PresenceOverride, - type PresenceSchedule, - type PresenceScheduleBody, + type PresencePattern, } from "../api/presence"; import { - cycleWeekIndex, + cycleLabel, dayState, - fullPattern, localTodayIso, monthDates, monthStart, - patternIncludes, - presenceRule, - resizePattern, - togglePatternDay, + patternDaysLabel, weekdayOf, type DayState, } from "./cycle"; -import { anchorLabel, dateRange, monthLabel, shortDate } from "./format"; +import { dateRange, monthLabel, shortDate } from "./format"; import { CalendarCheckGlyph, CalendarXGlyph, ChevronLeftGlyph, ChevronRightGlyph, + PencilGlyph, Trash2Glyph, } from "./glyphs"; import OverrideEditor from "./OverrideEditor"; +import PatternEditor from "./PatternEditor"; 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]; /** Sunday-first header; `weekdayOf` stays Monday=0. */ const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"]; @@ -58,7 +55,7 @@ export const STATE_LABELS: Record = { interface Loaded { children: AdminChild[]; - schedules: Record; + patterns: Record; overrides: PresenceOverride[]; } @@ -73,23 +70,22 @@ type DeleteState = | { kind: "deleting"; id: number } | { kind: "error"; id: number; message: string }; -type SaveState = - | { kind: "idle" } - | { kind: "saving" } - | { kind: "saved" } - | { kind: "error"; message: string }; +type EditorState = + | { kind: "closed" } + | { kind: "override" } + | { kind: "pattern"; pattern: PresencePattern | null }; async function loadSchedule(): Promise { 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))), + const [patterns, overrides] = await Promise.all([ + Promise.all(children.map((child) => fetchPresencePatterns(child.id))), fetchPresenceOverrides(), ]); return { children, - schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])), + patterns: Object.fromEntries(children.map((child, i) => [child.id, patterns[i]])), overrides, }; } @@ -100,10 +96,7 @@ function loadErrorMessage(error: unknown): string { : "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 { +function saveErrorMessage(error: unknown, fallback: string): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return error.detail; return fallback; @@ -121,15 +114,13 @@ function ScreenHeader({ sub }: { sub: string }) { 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; + /** Every pattern of the child; none means home every day. */ + patterns: PresencePattern[]; overrides: PresenceOverride[]; onMonth: (delta: number) => void; - onToggle: (date: string) => void; } -function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) { +function MonthGrid({ month, today, patterns, overrides, onMonth }: MonthGridProps) { const dates = monthDates(month); const leading = (weekdayOf(month) + 1) % 7; return ( @@ -167,23 +158,21 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo ); })} @@ -199,10 +188,83 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo Today +

+ Combined from every pattern: away beats home; overrides beat both. +

); } +interface PatternRowProps { + pattern: PresencePattern; + deletion: DeleteState; + onEdit: () => void; + onAskDelete: () => void; + onKeep: () => void; + onDelete: () => void; +} + +function PatternRow({ pattern, deletion, onEdit, onAskDelete, onKeep, onDelete }: PatternRowProps) { + const mine = + (deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === pattern.id; + return ( +
  • + + {pattern.kind === "home" ? : } + +
    +
    {pattern.name}
    +
    + {pattern.kind === "home" ? "Home" : "Away"} · {cycleLabel(pattern.cycle_length_weeks)} +
    +
    + {patternDaysLabel(pattern)} +
    +
    + {mine ? ( +
    + Delete pattern? + + +
    + ) : ( + <> + + + + )} +
  • + ); +} + interface OverrideRowProps { override: PresenceOverride; childName: string; @@ -271,21 +333,24 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp const [attempt, setAttempt] = useState(0); const [selectedId, setSelectedId] = useState(null); const [month, setMonth] = useState(() => monthStart(today)); - const [drafts, setDrafts] = useState>({}); - const [save, setSave] = useState({ kind: "idle" }); - const [editorOpen, setEditorOpen] = useState(false); + const [editor, setEditor] = useState({ kind: "closed" }); const [deletion, setDeletion] = useState({ kind: "idle" }); + const [patternDeletion, setPatternDeletion] = useState({ kind: "idle" }); const addRef = useRef(null); - const editorWasOpen = useRef(false); + const addPatternRef = useRef(null); + const openedEditor = useRef("closed"); - // The editor replaces this screen, so Add is remounted when it closes; - // hand focus back to it then. + // An editor replaces this screen, so the Add controls are remounted when + // it closes; hand focus back to the one that belongs to that editor. useEffect(() => { - if (editorWasOpen.current && !editorOpen) addRef.current?.focus(); - editorWasOpen.current = editorOpen; - }, [editorOpen]); + if (editor.kind === "closed") { + if (openedEditor.current === "override") addRef.current?.focus(); + if (openedEditor.current === "pattern") addPatternRef.current?.focus(); + } + openedEditor.current = editor.kind; + }, [editor.kind]); - /** Reload children, schedules and overrides in place (no loading flash). */ + /** Reload children, patterns and overrides in place (no loading flash). */ const refresh = () => loadSchedule() .then((data) => setState({ kind: "ready", data })) @@ -309,16 +374,31 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp }; }, [attempt]); - if (editorOpen && state.kind === "ready" && state.data.children.length > 0) { + if (editor.kind !== "closed" && state.kind === "ready" && state.data.children.length > 0) { const { children } = state.data; + const current = children.find((c) => c.id === selectedId) ?? children[0]; + if (editor.kind === "pattern") { + return ( + setEditor({ kind: "closed" })} + onSaved={() => { + setEditor({ kind: "closed" }); + void refresh(); + }} + /> + ); + } return ( c.id === selectedId)?.id ?? children[0].id} + initialChildId={current.id} today={today} - onCancel={() => setEditorOpen(false)} + onCancel={() => setEditor({ kind: "closed" })} onSaved={(override) => { - setEditorOpen(false); + setEditor({ kind: "closed" }); setSelectedId(override.child_id); void refresh(); }} @@ -348,28 +428,13 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp } 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 patterns = data.patterns[child.id] ?? []; const childOverrides = data.overrides.filter((o) => o.child_id === child.id); - sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`; + sub = + patterns.length === 0 + ? `${child.display_name} · no patterns, home every day` + : `${child.display_name} · ${patterns.length} ${patterns.length === 1 ? "pattern" : "patterns"}`; - 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) @@ -386,32 +451,21 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp }), ); }; - 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) })); + const confirmPatternDelete = (id: number) => { + setPatternDeletion({ kind: "deleting", id }); + deletePresencePattern(id) + .then(() => refresh()) + .then(() => setPatternDeletion({ kind: "idle" })) + .catch((error: unknown) => + setPatternDeletion({ + kind: "error", + id, + message: saveErrorMessage( + error, + "The pattern could not be deleted. Check your connection and try again.", + ), + }), + ); }; body = ( @@ -425,7 +479,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp aria-pressed={c.id === child.id} onClick={() => { setSelectedId(c.id); - setSave({ kind: "idle" }); + setPatternDeletion({ kind: "idle" }); }} > {c.display_name} @@ -437,74 +491,48 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp setMonth((m) => monthStart(m, delta))} - onToggle={toggle} /> -
    - - - -
    - {save.kind === "error" ? ( -

    - {save.message} -

    - ) : save.kind === "saved" ? ( -

    - Pattern saved. -

    - ) : null} -

    Presence pattern

    -
      - {data.children.map((c) => ( -
    • -
      -
      {c.display_name}
      -
      - {presenceRule(data.schedules[c.id] ?? null)} -
    • - ))} -
    +
    +

    Presence patterns

    + +
    + {patterns.length > 0 ? ( +
      + {patterns.map((pattern) => ( + setEditor({ kind: "pattern", pattern })} + onAskDelete={() => setPatternDeletion({ kind: "confirming", id: pattern.id })} + onKeep={() => setPatternDeletion({ kind: "idle" })} + onDelete={() => confirmPatternDelete(pattern.id)} + /> + ))} +
    + ) : ( +
    + No patterns for {child.display_name} — home every day. +
    + )} + {patternDeletion.kind === "error" ? ( +

    + {patternDeletion.message} +

    + ) : null}

    Overrides

    @@ -512,7 +540,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp ref={addRef} type="button" className="schedule-text-action" - onClick={() => setEditorOpen(true)} + onClick={() => setEditor({ kind: "override" })} > Add diff --git a/admin/src/schedule/cycle.test.ts b/admin/src/schedule/cycle.test.ts index 6473c6a..f18ac0f 100644 --- a/admin/src/schedule/cycle.test.ts +++ b/admin/src/schedule/cycle.test.ts @@ -1,23 +1,44 @@ import { describe, expect, it } from "vitest"; import { + cycleLabel, cycleWeekIndex, dayState, + emptyPattern, monthDates, monthStart, - presenceRule, + patternDaysLabel, + patternIncludes, resizePattern, togglePatternDay, weekdayOf, } from "./cycle"; -import type { PresenceOverride, PresenceScheduleBody } from "../api/presence"; +import type { PatternCycle, PatternKind, PresenceOverride } from "../api/presence"; -// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only. -const SCHEDULE: PresenceScheduleBody = { +type KindedCycle = PatternCycle & { kind: PatternKind }; + +// The migrated single schedule: anchor Monday 2026-09-07; week 0 home every +// day, week 1 home Sat/Sun only. +const SCHEDULE: KindedCycle = { + kind: "home", cycle_length_weeks: 2, anchor_date: "2026-09-07", pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] }, }; +// The owner scenario: away Thu+Fri every week, away weekends every 2 weeks. +const THU_FRI: KindedCycle = { + kind: "away", + cycle_length_weeks: 1, + anchor_date: "2026-09-07", + pattern: { 0: [3, 4] }, +}; +const ALTERNATE_WEEKENDS: KindedCycle = { + kind: "away", + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { 0: [5, 6], 1: [] }, +}; + describe("cycleWeekIndex", () => { it("follows the anchor across a month, wrapping every 2 weeks", () => { const expected: Record = {}; @@ -89,22 +110,61 @@ describe("dayState", () => { { 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("a home-only child: overrides first, then absent on days no home pattern covers", () => { + const patterns = [SCHEDULE]; + expect(dayState(patterns, overrides, "2026-09-08")).toBe("home"); + expect(dayState(patterns, overrides, "2026-09-11")).toBe("override-away"); + expect(dayState(patterns, overrides, "2026-09-15")).toBe("away"); + expect(dayState(patterns, overrides, "2026-09-16")).toBe("override-home"); + expect(dayState(patterns, overrides, "2026-09-19")).toBe("home"); + expect(dayState(patterns, overrides, "2026-09-01")).toBe("away"); + expect(dayState(patterns, 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"); + it("combines the owner's two away patterns; every other day is home", () => { + const patterns = [THU_FRI, ALTERNATE_WEEKENDS]; + const expected: Record = {}; + for (const date of monthDates("2026-09-01")) { + const weekday = weekdayOf(date); + const onWeek = cycleWeekIndex("2026-09-07", date, 2) === 0; + const away = weekday === 3 || weekday === 4 || (onWeek && weekday >= 5); + expected[date] = away ? "away" : "home"; + } + for (const [date, state] of Object.entries(expected)) { + expect(dayState(patterns, [], date), date).toBe(state); + } + // Spot checks: Thu/Fri every week; weekends alternate from the anchor. + expect(dayState(patterns, [], "2026-09-10")).toBe("away"); // Thu, week 0 + expect(dayState(patterns, [], "2026-09-18")).toBe("away"); // Fri, week 1 + expect(dayState(patterns, [], "2026-09-12")).toBe("away"); // Sat, week 0 + expect(dayState(patterns, [], "2026-09-20")).toBe("home"); // Sun, week 1 + expect(dayState(patterns, [], "2026-09-27")).toBe("away"); // Sun, week 0 + expect(dayState(patterns, [], "2026-09-15")).toBe("home"); // Tue + }); + + it("away beats home when both cover a date; a home pattern makes uncovered days away", () => { + const patterns = [SCHEDULE, THU_FRI]; + expect(dayState(patterns, [], "2026-09-10")).toBe("away"); // home week 0 Thu, but away wins + expect(dayState(patterns, [], "2026-09-08")).toBe("home"); // home covers, away does not + expect(dayState(patterns, [], "2026-09-15")).toBe("away"); // no home pattern covers it + // An override still beats every pattern. + expect( + dayState(patterns, [{ ...overrides[1], start_date: "2026-09-10", end_date: "2026-09-10" }], "2026-09-10"), + ).toBe("override-home"); + }); + + it("treats a child with no patterns as home every day", () => { + expect(dayState([], [], "2026-09-15")).toBe("home"); }); }); -describe("pattern editing and rule text", () => { +describe("pattern editing and labels", () => { + it("patternIncludes reads the date's cycle week from the anchor", () => { + expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-12")).toBe(true); + expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-19")).toBe(false); + expect(patternIncludes(ALTERNATE_WEEKENDS, "2026-09-05")).toBe(false); // pre-anchor week 1 + }); + it("toggles one weekday of one cycle week", () => { expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6], @@ -113,21 +173,19 @@ describe("pattern editing and rule text", () => { expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]); }); - it("resizes the pattern to the cycle length", () => { + it("resizes the pattern to the cycle length; added weeks cover no day", () => { 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]); + expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([]); + expect(emptyPattern(2)).toEqual({ 0: [], 1: [] }); }); - 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"); + it("describes the cycle and the weekday set in plain language", () => { + expect(cycleLabel(1)).toBe("Every week"); + expect(cycleLabel(2)).toBe("Every 2 weeks"); + expect(patternDaysLabel(THU_FRI)).toBe("Thu, Fri"); + expect(patternDaysLabel(ALTERNATE_WEEKENDS)).toBe("Week 1: Sat, Sun · Week 2: No days"); + expect(patternDaysLabel(SCHEDULE)).toBe("Week 1: Every day · Week 2: Sat, Sun"); + // Listed Monday-first by weekday number, whatever order they were stored in. + expect(patternDaysLabel({ ...THU_FRI, pattern: { "0": [6, 0] } })).toBe("Mon, Sun"); }); }); diff --git a/admin/src/schedule/cycle.ts b/admin/src/schedule/cycle.ts index ac16e6a..d8e3491 100644 --- a/admin/src/schedule/cycle.ts +++ b/admin/src/schedule/cycle.ts @@ -10,12 +10,23 @@ * 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"; +import type { + PatternCycle, + PatternKind, + PatternWeeks, + PresenceOverride, +} from "../api/presence"; const MS_PER_DAY = 86_400_000; export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; +/** Monday=0 weekday numbers in Sunday-first display order. */ +export const DISPLAY_WEEKDAYS = [6, 0, 1, 2, 3, 4, 5]; + +/** Short names indexed by Monday=0 weekday number. */ +export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + /** 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); @@ -67,44 +78,50 @@ export function monthDates(firstIso: string): string[] { 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]; +/** Every week of the cycle covers no day yet (a new pattern). */ +export function emptyPattern(cycleLengthWeeks: number): PatternWeeks { + const pattern: PatternWeeks = {}; + for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = []; 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)); +/** Does the pattern cover `date` (its cycle week lists the date's weekday)? */ +export function patternIncludes(cycle: PatternCycle, date: string): boolean { + const week = cycleWeekIndex(cycle.anchor_date, date, cycle.cycle_length_weeks); + return (cycle.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). + * A date's combined state for one child, mirroring core/presence.py + * `is_present` — first match wins: + * 1. an override covering the date; + * 2. away when any `away` pattern covers it; + * 3. home when any `home` pattern covers it; + * 4. away when the child has at least one `home` pattern (a home pattern + * lists the days the child IS here), else home (no patterns, or only + * `away` ones). */ export function dayState( - schedule: PresenceScheduleBody | null, + patterns: (PatternCycle & { kind: PatternKind })[], 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"; + if (patterns.some((p) => p.kind === "away" && patternIncludes(p, date))) return "away"; + const homes = patterns.filter((p) => p.kind === "home"); + if (homes.some((p) => patternIncludes(p, date))) return "home"; + return homes.length > 0 ? "away" : "home"; } /** Flip `weekday` in `week` of the pattern; returns a new pattern. */ export function togglePatternDay( - pattern: PresencePattern, + pattern: PatternWeeks, week: number, weekday: number, -): PresencePattern { +): PatternWeeks { const days = pattern[week] ?? []; const next = days.includes(weekday) ? days.filter((d) => d !== weekday) @@ -112,31 +129,35 @@ export function togglePatternDay( 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 = {}; +/** Fit the pattern to a new cycle length; added weeks start covering no day. */ +export function resizePattern(pattern: PatternWeeks, cycleLengthWeeks: number): PatternWeeks { + const next: PatternWeeks = {}; for (let week = 0; week < cycleLengthWeeks; week += 1) { - next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS]; + next[week] = pattern[week] ? [...pattern[week]] : []; } 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]}`; +function daysLabel(days: number[]): string { + if (days.length === 7) return "Every day"; + if (days.length === 0) return "No days"; + return [...days].sort((a, b) => a - b).map((d) => WEEKDAY_SHORT[d]).join(", "); } -/** 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; +/** + * Plain-language weekday set: `Thu, Fri` for a 1-week cycle, otherwise one + * entry per cycle week, e.g. `Week 1: Sat, Sun · Week 2: No days`. + */ +export function patternDaysLabel(cycle: PatternCycle): string { + if (cycle.cycle_length_weeks === 1) return daysLabel(cycle.pattern[0] ?? []); + const weeks: string[] = []; + for (let week = 0; week < cycle.cycle_length_weeks; week += 1) { + weeks.push(`Week ${week + 1}: ${daysLabel(cycle.pattern[week] ?? [])}`); } - if (full) return "Every day"; - if (weeks.length === 0) return "Never home"; - return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`; + return weeks.join(" · "); +} + +/** `Every week`, `Every 2 weeks`. */ +export function cycleLabel(cycleLengthWeeks: number): string { + return cycleLengthWeeks === 1 ? "Every week" : `Every ${cycleLengthWeeks} weeks`; } diff --git a/admin/src/schedule/glyphs.tsx b/admin/src/schedule/glyphs.tsx index 7419b42..4a7b88b 100644 --- a/admin/src/schedule/glyphs.tsx +++ b/admin/src/schedule/glyphs.tsx @@ -67,6 +67,15 @@ export function CalendarCheckGlyph({ size = 18 }: { size?: number }) { ); } +export function PencilGlyph({ size = 16 }: { size?: number }) { + return ( + + + + + ); +} + export function Trash2Glyph({ size = 16 }: { size?: number }) { return (