From d3c52974f5d004698e58a9b061f0651fe2cf196c Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Thu, 24 Sep 2026 19:33:22 -0400 Subject: [PATCH 1/7] Feature 19 follow-up: calendar week starts on Sunday (#230) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- admin/src/definitions/DefinitionsTab.test.tsx | 33 +++++++++++++++++++ admin/src/definitions/DefinitionsTab.tsx | 7 ++-- admin/src/definitions/format.ts | 5 ++- admin/src/schedule/ScheduleTab.test.tsx | 14 ++++++-- admin/src/schedule/ScheduleTab.tsx | 5 +-- admin/src/schedule/cycle.test.ts | 18 ++++++++++ 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index 038429b..b54475f 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -362,6 +362,39 @@ describe("DefinitionsTab", () => { expect(screen.getByText("4 definitions · 3 children")).toBeTruthy(); }); + it("the weekday picker lists Sunday first and Sunday still stores 6", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local + await renderReady(); + + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Feed the cat" }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), { + target: { value: "weekly" }, + }); + + const days = within(s).getByRole("group", { name: "Days" }); + expect( + within(days) + .getAllByRole("button") + .map((b) => b.getAttribute("aria-label")), + ).toEqual(["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]); + fireEvent.click(within(days).getByRole("button", { name: "Sunday" })); + fireEvent.click(within(s).getByRole("button", { name: "Evening" })); + + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + const body = api.writes()[0].body as { rule: DefinitionRule }; + expect(body.rule.weekday_set).toEqual([2, 6]); + expect(meta(await screen.findByTestId("definition-99"))).toContain("on Sun, Wed"); + }); + it("tapping a row opens the sheet in editing state and saving PATCHes", async () => { await renderReady(); const row = screen.getByTestId("definition-11"); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index b4803b5..5db5f16 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -27,6 +27,7 @@ import { } from "../api/definitions"; import { MONTH_SHORT, + WEEKDAY_DISPLAY_ORDER, WEEKDAY_SHORT, WINDOW_LABELS, WINDOW_ORDER, @@ -680,9 +681,9 @@ function EditSheet({ {draft.repeats === "weekly" || draft.repeats === "custom" ? (
- {WEEKDAY_SHORT.map((label, day) => ( + {WEEKDAY_DISPLAY_ORDER.map((day) => ( ))}
diff --git a/admin/src/definitions/format.ts b/admin/src/definitions/format.ts index a2246d4..2741859 100644 --- a/admin/src/definitions/format.ts +++ b/admin/src/definitions/format.ts @@ -21,6 +21,9 @@ export const WINDOW_LABELS: Record = { /** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */ export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +/** Display order for a week: Sunday first. The stored indices stay Monday=0. */ +export const WEEKDAY_DISPLAY_ORDER = [6, 0, 1, 2, 3, 4, 5]; + export const MONTH_SHORT = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", @@ -51,7 +54,7 @@ export function assigneeSummary( function weekdayList(days: number[] | null): string { return [...(days ?? [])] - .sort((a, b) => a - b) + .sort((a, b) => WEEKDAY_DISPLAY_ORDER.indexOf(a) - WEEKDAY_DISPLAY_ORDER.indexOf(b)) .map((day) => WEEKDAY_SHORT[day]) .join(", "); } diff --git a/admin/src/schedule/ScheduleTab.test.tsx b/admin/src/schedule/ScheduleTab.test.tsx index feae62c..24af4d0 100644 --- a/admin/src/schedule/ScheduleTab.test.tsx +++ b/admin/src/schedule/ScheduleTab.test.tsx @@ -131,10 +131,20 @@ 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"); - // September 2026 starts on a Tuesday: one leading pad after the initials. + // 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([ + "S", + "M", + "T", + "W", + "T", + "F", + "S", + ]); expect(grid.children[7].tagName).toBe("SPAN"); - expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01"); + expect(grid.children[8].tagName).toBe("SPAN"); + expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01"); const legend = screen.getByRole("list", { name: "Legend" }); expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([ diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index 6bd5eb4..3ab4384 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -46,7 +46,8 @@ 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"]; +/** Sunday-first header; `weekdayOf` stays Monday=0. */ +const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"]; export const STATE_LABELS: Record = { home: "Home", @@ -130,7 +131,7 @@ interface MonthGridProps { function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) { const dates = monthDates(month); - const leading = weekdayOf(month); + const leading = (weekdayOf(month) + 1) % 7; return ( <>
diff --git a/admin/src/schedule/cycle.test.ts b/admin/src/schedule/cycle.test.ts index 26217f5..6473c6a 100644 --- a/admin/src/schedule/cycle.test.ts +++ b/admin/src/schedule/cycle.test.ts @@ -58,6 +58,24 @@ describe("calendar helpers", () => { expect(weekdayOf("2026-09-06")).toBe(6); }); + it("weekdayOf keeps the stored Monday=0 .. Sunday=6 numbering (display order is separate)", () => { + // A full known week plus dates across month and year ends. + expect( + [ + "2026-09-07", + "2026-09-08", + "2026-09-09", + "2026-09-10", + "2026-09-11", + "2026-09-12", + "2026-09-13", + ].map(weekdayOf), + ).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(weekdayOf("2026-11-01")).toBe(6); // Sunday + expect(weekdayOf("2027-01-01")).toBe(4); // Friday + expect(weekdayOf("2024-02-29")).toBe(3); // Thursday + }); + 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"); -- 2.55.0 From c313e1c56f00af3d179ba3f1b37b89836189b7ee Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Thu, 24 Sep 2026 20:15:27 -0400 Subject: [PATCH 2/7] Feature: multiple presence patterns per child (schema 9) (#232) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- 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 | 255 ++++++ admin/src/schedule/ScheduleTab.css | 75 +- admin/src/schedule/ScheduleTab.test.tsx | 547 ++++++++++--- 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 + api/nestquest_core.py | 6 +- api/routes_admin.py | 340 +++++--- .../nestquest/core/dao_presence.py | 197 +++-- custom_components/nestquest/core/dao_rules.py | 10 +- .../nestquest/core/materialize.py | 61 +- .../nestquest/core/migrations.py | 81 +- custom_components/nestquest/core/presence.py | 209 +++-- .../nestquest/core/presence_management.py | 197 +++-- custom_components/nestquest/core/schema.py | 38 +- custom_components/nestquest/core/snapshot.py | 53 +- tests/test_api_admin_presence.py | 728 ++++++++++++++---- tests/test_api_admin_presence_consequence.py | 16 +- tests/test_api_admin_presence_reads.py | 127 ++- tests/test_api_panel.py | 8 +- tests/test_core_snapshot.py | 25 +- tests/test_dao_matrix.py | 47 +- tests/test_dao_presence.py | 461 +++++++---- tests/test_materialize.py | 179 ++++- tests/test_migrations.py | 345 ++++++++- tests/test_presence.py | 455 +++++++---- tests/test_presence_patterns.py | 165 ++++ tests/test_schema.py | 176 +++-- 33 files changed, 4157 insertions(+), 1381 deletions(-) create mode 100644 admin/src/schedule/PatternEditor.tsx create mode 100644 tests/test_presence_patterns.py 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..a19a795 --- /dev/null +++ b/admin/src/schedule/PatternEditor.tsx @@ -0,0 +1,255 @@ +/** + * 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; + // A pattern covering no day is valid (e.g. a migrated all-absent home schedule). + const canSave = body.name !== "" && isIsoDate(anchor) && 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..91b0e62 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,317 @@ 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( + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + expect(await screen.findByRole("heading", { name: "New pattern" })).toBeTruthy(); + // Nothing named yet. + expect(saveButton().disabled).toBe(true); + + 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); + + const before = calls().length; + fireEvent.click(saveButton()); + const row = await screen.findByTestId("pattern-50"); + expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull(); + + 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("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => { + it("Add can save a Home pattern", 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.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 + }); - // 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"); + it("Save requires a name; covered days alone are not enough", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Rory" })); + fireEvent.click(screen.getByRole("button", { name: "Add pattern" })); + await screen.findByRole("heading", { name: "New pattern" }); + fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" })); + expect(saveButton().disabled).toBe(true); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: " " } }); + expect(saveButton().disabled).toBe(true); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } }); + expect(saveButton().disabled).toBe(false); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "" } }); + expect(saveButton().disabled).toBe(true); + fireEvent.click(saveButton()); + expect(writes()).toEqual([]); + }); - fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); - expect(await screen.findByText("Pattern saved.")).toBeTruthy(); + it("Add saves a pattern that covers no weekday and lists it", async () => { + await renderReady(); + 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: "Always away" } }); + fireEvent.click(screen.getByRole("button", { name: "Home" })); + fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } }); + expect(saveButton().disabled).toBe(false); + fireEvent.click(saveButton()); + const row = await screen.findByTestId("pattern-50"); - 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({ + expect(writes()).toEqual([ + { + method: "POST", + path: "/api/v1/admin/children/3/presence-patterns", + body: { + name: "Always away", + kind: "home", + cycle_length_weeks: 2, + anchor_date: TODAY, + pattern: { "0": [], "1": [] }, + }, + }, + ]); + expect(findPattern(50)?.pattern).toEqual({ "0": [], "1": [] }); + expect(within(row).getByText("Always away")).toBeTruthy(); + expect(within(row).getByText("Home · Every 2 weeks")).toBeTruthy(); + expect(within(row).getByText("Week 1: No days · Week 2: No days")).toBeTruthy(); + // A home pattern covering no day: the child is away every day. + for (const date of monthDates("2026-09-01")) expect(day(date).dataset.state, date).toBe("away"); + }); + + it("Edit of a migrated all-absent home pattern saves without touching any weekday", async () => { + const migrated: PresencePattern = { + id: 7, + child_id: 3, + name: "Home weeks", + kind: "home", cycle_length_weeks: 2, anchor_date: "2026-09-07", - pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] }, - }); + pattern: { "0": [], "1": [] }, + }; + patterns[3] = [migrated]; + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Rory" })); + fireEvent.click(await screen.findByRole("button", { name: "Edit Home weeks" })); + await screen.findByRole("heading", { name: "Edit pattern" }); + for (const week of [1, 2]) { + for (const button of within(weekGroup(week)).getAllByRole("button")) { + expect(button.getAttribute("aria-pressed")).toBe("false"); + } + } + // Unchanged: nothing to save. + expect(saveButton().disabled).toBe(true); - 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( + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Never home" } }); + expect(saveButton().disabled).toBe(false); + fireEvent.click(saveButton()); + await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull()); + + expect(writes()).toEqual([{ method: "PATCH", path: `${PATTERNS_PATH}/7`, body: { name: "Never home" } }]); + expect(findPattern(7)).toEqual({ ...migrated, name: "Never home" }); + const row = await screen.findByTestId("pattern-7"); + expect(within(row).getByText("Week 1: No days · Week 2: No days")).toBeTruthy(); + }); + + 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", + ); + // 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("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("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("a 422 shows the API detail and keeps the draft", async () => { - putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422); + it("deleting the last pattern leaves the child with none: home every day", async () => { await renderReady(); - fireEvent.click(day("2026-09-01")); - fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); + 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("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(); + 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 ( diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 1fe8408..cb4ff1e 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -159,12 +159,12 @@ core_quest_definitions = importlib.import_module( core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence") #: The presence model (``core.presence.PresenceEngine``, -#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's +#: ``PresencePattern``, ``PresenceOverride``) the admin plane's #: presence routes serialize and the tests resolve presence with. core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence") #: The presence-write business layer -#: (``core.presence_management.set_presence_schedule`` et al.) the +#: (``core.presence_management.create_presence_pattern`` et al.) the #: admin plane's presence routes call — the ONE implementation the HA #: services share. core_presence_management = importlib.import_module( @@ -172,7 +172,7 @@ core_presence_management = importlib.import_module( ) #: The typed presence DAOs (``core.dao_presence``) the admin-plane tests -#: read stored schedules and overrides back through. +#: read stored patterns and overrides back through. core_dao_presence = importlib.import_module( f"{_CORE_MODULE_NAME}.dao_presence" ) diff --git a/api/routes_admin.py b/api/routes_admin.py index 2ad7874..b70e8eb 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -39,15 +39,27 @@ The presence routes follow the same pattern over :mod:`nestquest_core.presence_management` — the ONE presence write layer the HA services share, so the two planes cannot drift: -- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the - child's repeating schedule: the body's ``cycle_length_weeks``, - ``anchor_date`` and ``pattern`` go straight into ONE - :class:`~nestquest_core.presence.PresenceSchedule` (the model - validates cycle length, the strict anchor date and the pattern's - week coverage at construction), the core upserts by child (setting - again REPLACES the one schedule a child can have) and regenerates - the child's future instances; the response is the STORED schedule - decoded back through the model. +- ``GET /children/{child_id}/presence-patterns`` lists the child's + repeating presence patterns (zero or more, schema 9) through ONE + ``list_presence_patterns`` call, wrapped as ``{"patterns": [...]}`` + ordered by id — an empty list when the child has none (present + every day), never a 404; an unknown child is 404. +- ``POST /children/{child_id}/presence-patterns`` adds one pattern: + the body's ``name``, ``kind`` (``home``/``away``), + ``cycle_length_weeks``, ``anchor_date`` and ``pattern`` go straight + into ONE :class:`~nestquest_core.presence.PresencePattern` (the model + validates the name, the kind, cycle length, the strict anchor date + and the pattern's week coverage at construction), the core inserts + it beside the child's other patterns and regenerates the child's + future instances; the response (201) is the STORED pattern with its + ``id``, its weekday sets decoded back through the model. +- ``PATCH /presence-patterns/{pattern_id}`` edits one pattern: only + SUPPLIED fields are forwarded (``exclude_unset``), the core lays them + over the stored pattern and rebuilds the WHOLE model, so a partial + edit is validated exactly as a create; it regenerates the pattern's + child. +- ``DELETE /presence-patterns/{pattern_id}`` deletes one pattern and + regenerates its child. It answers ``{"status": "ok"}``. - ``POST /presence-overrides`` creates one date-range override: the body becomes ONE :class:`~nestquest_core.presence.PresenceOverride` (the model validates the dates, ``end >= start``, the real bool and @@ -65,11 +77,6 @@ layer the HA services share, so the two planes cannot drift: (instances with a completion event are never counted, D-005). Nothing is written; an unknown child is 404, a malformed or inverted range 422. -- ``GET /children/{child_id}/presence-schedule`` reads the child's - schedule through ONE ``get_presence_schedule`` call, wrapped as - ``{"schedule": ...}`` in the PUT response's shape — ``null`` when the - child has none (present every day), never a 404; an unknown child is - 404. - ``GET /presence-overrides`` lists the household's overrides through ONE ``list_presence_overrides`` call, wrapped as ``{"overrides": [...]}`` in the POST response's shape, ordered by @@ -238,21 +245,24 @@ deliberately narrow): ``rule_type``, a shape-missing field such as a weekly rule without weekdays, a malformed date, a forbidden field), a bad window name or due time, a rejected window or assignee list (empty, duplicate, - wrong-typed); a rejected presence schedule or override (bad cycle - length, a pattern not covering the cycle's weeks, a malformed or + wrong-typed); a rejected presence pattern or override (a blank name, + an unknown kind, a bad cycle length, a pattern not covering the + cycle's weeks, a malformed anchor date, a malformed or inverted date range, an override overlapping the same child's existing one) — is mapped to 422: the body was well-formed JSON but semantically invalid, the same failure class Pydantic reports. The core rejects these BEFORE any write, so a 422 never leaves a half-applied change behind. -- A non-existent CHILD on a presence route (setting a schedule or - creating an override for an unknown id) is mapped to 404 through the - SAME :func:`_raise_child_error` the children routes use — the - message reads ``child N does not exist``. A non-existent OVERRIDE - on the delete route is mapped to 404 through +- A non-existent CHILD on a presence route (listing or creating a + pattern, or creating an override, for an unknown id) is mapped to 404 + through the SAME :func:`_raise_child_error` the children routes use — + the message reads ``child N does not exist``. A non-existent + OVERRIDE on the delete route is mapped to 404 through :func:`_raise_presence_override_error`, keyed on the ``override_id:`` prefix the core's unknown-id error carries (the - same convention as the quest-definition 404). + same convention as the quest-definition 404); a non-existent PATTERN + on the pattern edit/delete routes likewise through + :func:`_raise_presence_pattern_error`, keyed on ``pattern_id:``. - The UNCOMPLETE route maps the core completion layer's unknown- ``instance_id`` error (prefixed ``instance_id:``, the same convention the panel complete route keys on) to 404; every other @@ -327,7 +337,6 @@ from api.nestquest_core import ( core_dao_rules, core_history, core_materialize, - core_presence, core_presence_management, core_quest_definitions, core_recurrence, @@ -605,37 +614,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel): is_active: bool -class AdminPresenceScheduleRequest(BaseModel): - """The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``. +class AdminPresencePatternCreateRequest(BaseModel): + """The body of ``POST /api/v1/admin/children/{child_id}/presence-patterns``. - The shape of :class:`~nestquest_core.presence.PresenceSchedule` — + The shape of :class:`~nestquest_core.presence.PresencePattern` — nothing more. ``pattern`` maps each cycle week index (JSON object - keys, coerced to ints by the model) to that week's present weekdays - (Monday=0); every week of the cycle must be covered, which the - PRESENCE MODEL enforces at construction, not this shape. A week - with an empty list means absent every day of that week. + keys, coerced to ints by the model) to the weekdays the pattern + covers that week (Monday=0); every week of the cycle must be + covered, which the PRESENCE MODEL enforces at construction, not + this shape — as it does the non-blank ``name`` and the + ``home``/``away`` ``kind``. ``cycle_length_weeks`` and the weekday + values are strict JSON integers: ``2.0``, ``"2"`` or ``true`` is a + 422, not a silent coercion. """ - cycle_length_weeks: int + name: str + kind: str + cycle_length_weeks: StrictInt anchor_date: str - pattern: dict[int, list[int]] + pattern: dict[int, list[StrictInt]] -class AdminPresenceScheduleResponse(BaseModel): - """A child's repeating presence schedule as the admin plane serializes it. +class AdminPresencePatternEditRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/presence-patterns/{pattern_id}``. - The stored schedule decoded back through - :meth:`nestquest_core.presence.PresenceSchedule.decode`: each - pattern week is the sorted list of present weekdays, and the child - has exactly ONE schedule (setting again replaces it). + Every field optional; only SUPPLIED fields are forwarded, and an + explicit ``null`` is forwarded too — and rejected by the model. + The pattern's child cannot be changed. """ + name: str | None = None + kind: str | None = None + cycle_length_weeks: StrictInt | None = None + anchor_date: str | None = None + pattern: dict[int, list[StrictInt]] | None = None + + +class AdminPresencePatternResponse(BaseModel): + """One presence pattern as the admin plane serializes it. + + The stored row with its weekday sets decoded back through + :meth:`nestquest_core.presence.PresencePattern.decode`: each + pattern week is the sorted list of covered weekdays. The ``id`` is + the handle the PATCH and DELETE routes take. + """ + + id: int child_id: int + name: str + kind: str cycle_length_weeks: int anchor_date: str pattern: dict[int, list[int]] +class AdminPresencePatternListResponse(BaseModel): + """The ``GET /children/{child_id}/presence-patterns`` payload. + + ``patterns`` is empty when the child has none — the child is + present every day (Feature 05); absence is state, not an error. + """ + + patterns: list[AdminPresencePatternResponse] + + class AdminPresenceOverrideCreateRequest(BaseModel): """The body of ``POST /api/v1/admin/presence-overrides``. @@ -669,16 +711,6 @@ class AdminPresenceOverrideResponse(BaseModel): note: str | None -class AdminPresenceScheduleReadResponse(BaseModel): - """The ``GET /children/{child_id}/presence-schedule`` payload. - - ``schedule`` is ``None`` when the child has no schedule — the child - is present every day (Feature 05); absence is state, not an error. - """ - - schedule: AdminPresenceScheduleResponse | None - - class AdminPresenceOverrideListResponse(BaseModel): """The ``GET /presence-overrides`` payload: the matching overrides.""" @@ -989,8 +1021,9 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn: ``override_id:`` (an unknown id), and that one case is this route's 404. Every other ``ValueError`` — a non-integer id the path parser's ``int`` already makes unreachable — becomes 422. The - schedule and override-CREATE routes do NOT use this mapper: their - only 404 is an unknown CHILD, mapped by :func:`_raise_child_error`. + pattern list/create and override-CREATE routes do NOT use this + mapper: their only 404 is an unknown CHILD, mapped by + :func:`_raise_child_error`. """ if str(error).startswith("override_id:"): raise HTTPException( @@ -999,6 +1032,26 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +#: 404 detail for a pattern id the core layer reports as non-existent. +_PRESENCE_PATTERN_NOT_FOUND_DETAIL = "Presence pattern not found" + + +def _raise_presence_pattern_error(error: ValueError) -> NoReturn: + """Map a core ``ValueError`` from the pattern PATCH/DELETE routes. + + Mirrors :func:`_raise_presence_override_error`: the core names a + missing pattern by prefixing its error with ``pattern_id:``, and + that one case is 404. Every other ``ValueError`` — a rejected edit + (blank name, unknown kind, bad cycle length, malformed anchor date + or pattern), each naming its field — becomes 422. + """ + if str(error).startswith("pattern_id:"): + raise HTTPException( + status_code=404, detail=_PRESENCE_PATTERN_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + #: 404 detail for an instance id the core layer reports as non-existent. _QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found" @@ -1169,16 +1222,20 @@ def _quest_definition_response( ) -def _presence_schedule_response( - schedule: core_presence.PresenceSchedule, -) -> AdminPresenceScheduleResponse: - """Serialize one decoded core PresenceSchedule into the payload.""" - return AdminPresenceScheduleResponse( - child_id=schedule.child_id, - cycle_length_weeks=schedule.cycle_length_weeks, - anchor_date=schedule.anchor_date.isoformat(), +def _presence_pattern_response( + record: core_presence_management.PresencePatternRecord, +) -> AdminPresencePatternResponse: + """Serialize one stored core pattern, weekday sets decoded.""" + pattern = core_presence_management.decode_pattern_record(record) + return AdminPresencePatternResponse( + id=record.id, + child_id=pattern.child_id, + name=pattern.name, + kind=pattern.kind, + cycle_length_weeks=pattern.cycle_length_weeks, + anchor_date=pattern.anchor_date.isoformat(), pattern={ - week: sorted(days) for week, days in schedule.pattern.items() + week: sorted(days) for week, days in pattern.pattern.items() }, ) @@ -1545,40 +1602,136 @@ async def admin_set_quest_definition_active( return _quest_definition_response(bundle) -@router.put( - "/children/{child_id}/presence-schedule", - summary="Set a child's repeating presence schedule", - response_model=AdminPresenceScheduleResponse, +@router.get( + "/children/{child_id}/presence-patterns", + summary="List a child's repeating presence patterns", + response_model=AdminPresencePatternListResponse, ) -async def admin_set_presence_schedule( - child_id: int, body: AdminPresenceScheduleRequest, request: Request -) -> AdminPresenceScheduleResponse: - """Set (upsert) a child's repeating presence schedule. +async def admin_list_presence_patterns( + child_id: int, request: Request +) -> AdminPresencePatternListResponse: + """Return the child's presence patterns, ordered by id. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: one + :func:`nestquest_core.presence_management.list_presence_patterns` + call, each pattern serialized exactly as the POST response is; a + child without one answers ``{"patterns": []}`` (present every day). + Errors are mapped by :func:`_raise_child_error`: an unknown child + is 404. + """ + state: DatabaseState = request.app.state.db + try: + records = await core_presence_management.list_presence_patterns( + state.database, child_id + ) + except ValueError as error: + _raise_child_error(error) + return AdminPresencePatternListResponse( + patterns=[_presence_pattern_response(record) for record in records] + ) + + +@router.post( + "/children/{child_id}/presence-patterns", + summary="Add a repeating presence pattern to a child", + status_code=201, + response_model=AdminPresencePatternResponse, +) +async def admin_create_presence_pattern( + child_id: int, body: AdminPresencePatternCreateRequest, request: Request +) -> AdminPresencePatternResponse: + """Add one repeating presence pattern to a child. Requires a valid admin JWT (the router's shared :func:`~api.auth.require_admin` dependency — the ONE check; this handler performs NO auth of its own). Thin adapter: the body model - carries the shape, and the cycle-length, anchor-date and - pattern-coverage policies all live in the - :class:`~nestquest_core.presence.PresenceSchedule` constructor the - core builds the schedule with — setting again REPLACES the child's - one schedule, and the core regenerates the child's future instances - so they track the new presence. Errors are mapped by - :func:`_raise_child_error`: an unknown child is 404, a rejected - schedule 422. + carries the shape, and the name, kind, cycle-length, anchor-date + and pattern-coverage policies all live in the + :class:`~nestquest_core.presence.PresencePattern` constructor the + core builds the pattern with; the core regenerates the child's + future instances so they track the new presence. Errors are + mapped by :func:`_raise_child_error`: an unknown child is 404, a + rejected pattern 422. """ state: DatabaseState = request.app.state.db try: - schedule = await core_presence_management.set_presence_schedule( + record = await core_presence_management.create_presence_pattern( state.database, child_id, + body.name, + body.kind, body.cycle_length_weeks, body.anchor_date, body.pattern, ) except ValueError as error: _raise_child_error(error) - return _presence_schedule_response(schedule) + return _presence_pattern_response(record) + + +@router.patch( + "/presence-patterns/{pattern_id}", + summary="Edit a repeating presence pattern", + response_model=AdminPresencePatternResponse, +) +async def admin_edit_presence_pattern( + pattern_id: int, body: AdminPresencePatternEditRequest, request: Request +) -> AdminPresencePatternResponse: + """Edit one presence pattern; only supplied fields change. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the supplied + fields are forwarded with ``exclude_unset`` semantics to + :func:`nestquest_core.presence_management.update_presence_pattern`, + which rebuilds the whole pattern through the model (an explicit + ``null`` is rejected there) and regenerates the pattern's child. + Error mapping (:func:`_raise_presence_pattern_error`): an unknown + pattern id is 404; a rejected edit is 422. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_presence_management.update_presence_pattern( + state.database, + pattern_id, + **body.model_dump(exclude_unset=True), + ) + except ValueError as error: + _raise_presence_pattern_error(error) + return _presence_pattern_response(record) + + +@router.delete( + "/presence-patterns/{pattern_id}", + summary="Delete a repeating presence pattern", +) +async def admin_delete_presence_pattern( + pattern_id: int, request: Request +) -> dict[str, str]: + """Delete one presence pattern. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the id goes + straight to + :func:`nestquest_core.presence_management.delete_presence_pattern`, + which looks the pattern up first (an unknown id is refused BEFORE + any delete), removes it and regenerates ITS child's future + instances. On success the route answers ``{"status": "ok"}``. + + Error mapping (:func:`_raise_presence_pattern_error`): an unknown + pattern id is 404; every other core ``ValueError`` is 422. + """ + state: DatabaseState = request.app.state.db + try: + await core_presence_management.delete_presence_pattern( + state.database, pattern_id + ) + except ValueError as error: + _raise_presence_pattern_error(error) + return {"status": "ok"} @router.post( @@ -1694,39 +1847,6 @@ async def admin_presence_override_consequence( return AdminPresenceOverrideConsequenceResponse(removed=removed) -@router.get( - "/children/{child_id}/presence-schedule", - summary="Read a child's repeating presence schedule", - response_model=AdminPresenceScheduleReadResponse, -) -async def admin_get_presence_schedule( - child_id: int, request: Request -) -> AdminPresenceScheduleReadResponse: - """Return the child's presence schedule, or ``schedule: null``. - - Requires a valid admin JWT (the router's shared - :func:`~api.auth.require_admin` dependency — the ONE check; this - handler performs NO auth of its own). Thin adapter: one - :func:`nestquest_core.presence_management.get_presence_schedule` - call; a stored schedule is serialized exactly as the PUT response - is, and a child without one answers ``{"schedule": null}`` (present - every day). Errors are mapped by :func:`_raise_child_error`: an - unknown child is 404. - """ - state: DatabaseState = request.app.state.db - try: - schedule = await core_presence_management.get_presence_schedule( - state.database, child_id - ) - except ValueError as error: - _raise_child_error(error) - return AdminPresenceScheduleReadResponse( - schedule=( - None if schedule is None else _presence_schedule_response(schedule) - ) - ) - - @router.get( "/presence-overrides", summary="List presence overrides", diff --git a/custom_components/nestquest/core/dao_presence.py b/custom_components/nestquest/core/dao_presence.py index 8aa5020..23f54b6 100644 --- a/custom_components/nestquest/core/dao_presence.py +++ b/custom_components/nestquest/core/dao_presence.py @@ -1,4 +1,4 @@ -"""Typed DAO layer for the presence_schedules and presence_overrides tables. +"""Typed DAO layer for the presence_patterns and presence_overrides tables. All SQL for these tables lives in this module per the feature guardrails: callers get typed dataclasses back and never see raw rows @@ -8,11 +8,12 @@ executor and the event loop never blocks. Semantics per the done-condition and Feature 05: -- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting - by child updates the existing row rather than raising. -- A child with NO schedule row is present every day — the absence of a - row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child` - returning None, never by an empty pattern row. +- A child has ZERO OR MORE presence patterns (schema 9 — no UNIQUE on + ``child_id``); each is created, updated and deleted by its own id. +- A child with NO pattern row is present every day — the absence of a + row is meaningful state, expressed by + :meth:`PresencePatternsDao.list_by_child` returning an empty list, + never by an empty pattern row. - Overrides beat the pattern for their date range (that resolution is the presence engine's job, Feature 05); the DAO only stores and lists them. A single-day override stores the same date as both @@ -32,11 +33,13 @@ from .db import NestQuestDatabase @dataclass(frozen=True) -class PresenceScheduleRecord: - """One row of ``presence_schedules``.""" +class PresencePatternRecord: + """One row of ``presence_patterns``.""" id: int child_id: int + name: str + kind: str cycle_length_weeks: int anchor_date: str pattern: str @@ -54,21 +57,23 @@ class PresenceOverrideRecord: note: str | None -_SCHEDULE_COLUMNS = ( - "id, child_id, cycle_length_weeks, anchor_date, pattern" +_PATTERN_COLUMNS = ( + "id, child_id, name, kind, cycle_length_weeks, anchor_date, pattern" ) _OVERRIDE_COLUMNS = ( "id, child_id, start_date, end_date, is_present, note" ) -def _schedule_from_row(row: tuple) -> PresenceScheduleRecord: - return PresenceScheduleRecord( +def _pattern_from_row(row: tuple) -> PresencePatternRecord: + return PresencePatternRecord( id=row[0], child_id=row[1], - cycle_length_weeks=row[2], - anchor_date=row[3], - pattern=row[4], + name=row[2], + kind=row[3], + cycle_length_weeks=row[4], + anchor_date=row[5], + pattern=row[6], ) @@ -83,29 +88,27 @@ def _override_from_row(row: tuple) -> PresenceOverrideRecord: ) -class PresenceSchedulesDao: - """Typed async access to the ``presence_schedules`` table.""" +class PresencePatternsDao: + """Typed async access to the ``presence_patterns`` table.""" def __init__(self, database: NestQuestDatabase) -> None: self._database = database - async def upsert_by_child( + async def create( self, child_id: int, + name: str, + kind: str, cycle_length_weeks: int, anchor_date: str, pattern: str, - ) -> PresenceScheduleRecord: - """Set the child's presence schedule, replacing any existing one. + ) -> PresencePatternRecord: + """Insert one pattern for the child and return it as stored. - Upsert semantics are the done-condition: a second schedule for - the same child updates the existing row rather than raising. - Runs under the connection lock so a concurrent upsert/delete - for the same child serializes instead of colliding on the - schema's UNIQUE(child_id). Pattern validity (segment shape, - segment count = cycle_length_weeks) is enforced by the schema - CHECKs; the child must exist (FK) — validated atomically here - so a deleted child cannot pass a stale check. + The child must exist (FK) — validated atomically under the + connection lock so a deleted child cannot pass a stale check. + ``kind``, the cycle length and the pattern shape (segment count + = ``cycle_length_weeks``) are enforced by the schema CHECKs. """ _validate_date(anchor_date, "anchor_date") async with _connection_lock(self._database): @@ -115,53 +118,98 @@ class PresenceSchedulesDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") - await self._database.execute( - "INSERT INTO presence_schedules (child_id, " + result = await self._database.execute( + "INSERT INTO presence_patterns (child_id, name, kind, " "cycle_length_weeks, anchor_date, pattern) " - "VALUES (?, ?, ?, ?) " - "ON CONFLICT (child_id) DO UPDATE SET " - "cycle_length_weeks = excluded.cycle_length_weeks, " - "anchor_date = excluded.anchor_date, " - "pattern = excluded.pattern", - (child_id, cycle_length_weeks, anchor_date, pattern), + "VALUES (?, ?, ?, ?, ?, ?)", + ( + child_id, + name, + kind, + cycle_length_weeks, + anchor_date, + pattern, + ), ) - schedule = await self.get_by_child(child_id) - assert schedule is not None - return schedule + record = await self.get(result.lastrowid) + assert record is not None + return record - async def get_by_child( - self, child_id: int - ) -> PresenceScheduleRecord | None: - """Return the child's schedule, or None when the child has none. + async def list_by_child(self, child_id: int) -> list[PresencePatternRecord]: + """Return the child's patterns ordered by id; empty when none. - None is meaningful and UNAMBIGUOUS here: the child exists and - has no schedule row, i.e. the child is present every day - (Feature 05 guardrail). An unknown child id raises ValueError - instead of returning None, because "nonexistent child" must - never be silently read as "present every day". + An empty list is meaningful and UNAMBIGUOUS here: the child + exists and has no pattern row, i.e. the child is present every + day (Feature 05 guardrail). An unknown child id raises + ValueError instead of returning an empty list, because + "nonexistent child" must never be silently read as "present + every day". """ child = await self._database.fetch_one( "SELECT 1 FROM children WHERE id = ?", (child_id,) ) if child is None: raise ValueError(f"child {child_id} does not exist") - row = await self._database.fetch_one( - f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " - "WHERE child_id = ?", + rows = await self._database.fetch_all( + f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns " + "WHERE child_id = ? ORDER BY id", (child_id,), ) - return _schedule_from_row(row) if row is not None else None + return [_pattern_from_row(row) for row in rows] - async def delete(self, child_id: int) -> bool: - """Remove the child's schedule; True if a row was removed. + async def get(self, pattern_id: int) -> PresencePatternRecord | None: + """Return one pattern by id, or None when it does not exist.""" + row = await self._database.fetch_one( + f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns WHERE id = ?", + (pattern_id,), + ) + return _pattern_from_row(row) if row is not None else None - After deletion the child is present every day (no row means - no restriction). Deleting a schedule the child never had - returns False. + async def update( + self, + pattern_id: int, + name: str, + kind: str, + cycle_length_weeks: int, + anchor_date: str, + pattern: str, + ) -> PresencePatternRecord | None: + """Replace one pattern's fields; None when the id does not exist. + + Every column but ``id`` and ``child_id`` is rewritten — a + pattern never moves to another child. The schema CHECKs apply + exactly as on :meth:`create`. + """ + _validate_date(anchor_date, "anchor_date") + async with _connection_lock(self._database): + async with self._database.transaction(): + result = await self._database.execute( + "UPDATE presence_patterns SET name = ?, kind = ?, " + "cycle_length_weeks = ?, anchor_date = ?, pattern = ? " + "WHERE id = ?", + ( + name, + kind, + cycle_length_weeks, + anchor_date, + pattern, + pattern_id, + ), + ) + if result.rowcount == 0: + return None + record = await self.get(pattern_id) + return record + + async def delete(self, pattern_id: int) -> bool: + """Remove one pattern by id; True if a row was removed. + + Deleting a child's last pattern leaves it present every day (no + row means no restriction). """ result = await self._database.execute( - "DELETE FROM presence_schedules WHERE child_id = ?", - (child_id,), + "DELETE FROM presence_patterns WHERE id = ?", + (pattern_id,), ) return result.rowcount > 0 @@ -345,22 +393,23 @@ async def read_snapshot_unlocked( range_start: str, range_end: str, ) -> tuple[ - dict[int, PresenceScheduleRecord], + dict[int, list[PresencePatternRecord]], dict[int, list[PresenceOverrideRecord]], ]: - """Read schedules and overrides for ``child_ids`` WITHOUT locking. + """Read patterns and overrides for ``child_ids`` WITHOUT locking. MUST be called inside the connection lock and an open transaction, so - the schedules and overrides returned here stay coherent with every + the patterns and overrides returned here stay coherent with every other table the caller reads inside that same transaction (the materialization walk's single input snapshot does exactly that). The date range comes already validated by the caller. - Returns ``(schedules, overrides)``: + Returns ``(patterns, overrides)``: - - ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord` - for every child that HAS a schedule row. A child absent from the - mapping has no schedule, i.e. is present every day (Feature 05). + - ``patterns`` maps ``child_id`` to its :class:`PresencePatternRecord` + list (ordered by id) for every child that HAS a pattern row. A + child absent from the mapping has no pattern, i.e. is present + every day (Feature 05). - ``overrides`` maps ``child_id`` to that child's overrides overlapping ``[range_start, range_end]`` (inclusive boundaries), ordered by start date; a child with no matching override is absent from the mapping. @@ -372,9 +421,9 @@ async def read_snapshot_unlocked( if not children: return {}, {} placeholders = ",".join("?" for _ in children) - schedule_rows = await database.fetch_all( - f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules " - f"WHERE child_id IN ({placeholders})", + pattern_rows = await database.fetch_all( + f"SELECT {_PATTERN_COLUMNS} FROM presence_patterns " + f"WHERE child_id IN ({placeholders}) ORDER BY id", tuple(children), ) override_rows = await database.fetch_all( @@ -383,12 +432,12 @@ async def read_snapshot_unlocked( "AND start_date <= ? ORDER BY start_date", tuple(children) + (range_start, range_end), ) - schedules = { - record.child_id: record - for record in map(_schedule_from_row, schedule_rows) - } + patterns: dict[int, list[PresencePatternRecord]] = {} + for row in pattern_rows: + record = _pattern_from_row(row) + patterns.setdefault(record.child_id, []).append(record) overrides: dict[int, list[PresenceOverrideRecord]] = {} for row in override_rows: record = _override_from_row(row) overrides.setdefault(record.child_id, []).append(record) - return schedules, overrides \ No newline at end of file + return patterns, overrides \ No newline at end of file diff --git a/custom_components/nestquest/core/dao_rules.py b/custom_components/nestquest/core/dao_rules.py index 4f005d9..c4a5472 100644 --- a/custom_components/nestquest/core/dao_rules.py +++ b/custom_components/nestquest/core/dao_rules.py @@ -1319,16 +1319,16 @@ async def load_materialization_input( ): """Read every materialization input in ONE locked transaction. - Returns ``(snapshots, schedules, overrides)``: + Returns ``(snapshots, patterns, overrides)``: - ``snapshots``: every active definition with its rule, assignees and windows (the :class:`QuestDefinitionSnapshot` shape). - - ``schedules`` / ``overrides``: the presence schedules and + - ``patterns`` / ``overrides``: the presence patterns and range-scoped overrides of every assignee child, in :mod:`.dao_presence`'s record shapes. The whole read — definitions, rules, assignees, windows, and the - assignees' presence schedules and overrides — runs inside a single + assignees' presence patterns and overrides — runs inside a single BEGIN..COMMIT span under the connection lock, so a concurrent edit (definition, assignment, window, or presence) cannot interleave between the parts and the walk never sees a mixed-time view. Each @@ -1349,7 +1349,7 @@ async def load_materialization_input( ) from . import dao_presence # lazy: dao_presence imports us - schedules, overrides = await dao_presence.read_snapshot_unlocked( + patterns, overrides = await dao_presence.read_snapshot_unlocked( database, child_ids, range_start, range_end ) - return snapshots, schedules, overrides \ No newline at end of file + return snapshots, patterns, overrides \ No newline at end of file diff --git a/custom_components/nestquest/core/materialize.py b/custom_components/nestquest/core/materialize.py index ffeeefa..2f7429b 100644 --- a/custom_components/nestquest/core/materialize.py +++ b/custom_components/nestquest/core/materialize.py @@ -9,7 +9,7 @@ task and lives elsewhere. The walk is deterministic and idempotent: - All inputs — the active definitions with their rules, assignees and - windows, AND the assignee children's presence schedules and scoped + windows, AND the assignee children's presence patterns and scoped overrides — are read inside ONE locked transaction (see :func:`~.dao_rules.load_materialization_input`), so the walk sees a single coherent snapshot rather than a definition state from one @@ -51,12 +51,12 @@ never the past — and re-run the walk over the rolling horizon HOOK (Feature 09 presence services): the presence business layer that lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY presence-state write so a child's future instances track its presence: -``set_presence_pattern`` (including clearing/setting an empty pattern), -deleting/clearing the presence schedule -(:meth:`~.dao_presence.PresenceSchedulesDao.delete`), +creating, updating and deleting a presence pattern +(:mod:`.presence_management`'s ``create_presence_pattern`` / +``update_presence_pattern`` / ``delete_presence_pattern``), ``create_presence_override``, and ``delete_presence_override``. A -schedule delete changes presence state too (the child becomes -always-present), so it must regenerate just like the others. This hook +pattern delete changes presence state too, so it must regenerate just +like the others. This hook is documented, not yet wired: no presence business layer exists yet, and the presence DAO (``dao_presence.py``) must stay free of business rules — never call :func:`regenerate_for_child` from inside the DAO layer. @@ -79,7 +79,7 @@ from .dao_rules import ( schedule_rule_storage_from_record, ) from .db import NestQuestDatabase -from .presence import PresenceEngine, PresenceOverride, PresenceSchedule +from .presence import PresenceEngine, PresenceOverride, PresencePattern from .recurrence import ScheduleRule, occurs_on @@ -129,7 +129,7 @@ def _decode_rule(snapshot) -> ScheduleRule: def _build_engine( - schedules_records, + patterns_records, overrides_records, *, extra_overrides: tuple[PresenceOverride, ...] = (), @@ -140,11 +140,18 @@ def _build_engine( overrides on top of the stored ones — the consequence preview's proposed override (:func:`count_removed_by_override`). """ - schedules: dict[int, PresenceSchedule] = {} - for child_id, record in schedules_records.items(): - schedules[child_id] = PresenceSchedule.decode( - child_id, record.anchor_date, record.pattern - ) + patterns: dict[int, list[PresencePattern]] = {} + for child_id, records in patterns_records.items(): + patterns[child_id] = [ + PresencePattern.decode( + record.child_id, + record.name, + record.kind, + record.anchor_date, + record.pattern, + ) + for record in records + ] overrides: dict[int, list[PresenceOverride]] = {} for child_id, records in overrides_records.items(): overrides[child_id] = [ @@ -159,7 +166,7 @@ def _build_engine( ] for override in extra_overrides: overrides.setdefault(override.child_id, []).append(override) - return PresenceEngine(schedules, overrides) + return PresenceEngine(patterns, overrides) class GeneratedTuple(NamedTuple): @@ -279,10 +286,10 @@ async def materialize( f"{end_date!r} < {start_date!r}" ) - snapshots, schedules_records, overrides_records = await ( + snapshots, patterns_records, overrides_records = await ( load_materialization_input(database, start_date, end_date) ) - engine = _build_engine(schedules_records, overrides_records) + engine = _build_engine(patterns_records, overrides_records) child_filter = None if child_ids is None else set(child_ids) generated_at = _now_stamp() @@ -366,12 +373,11 @@ async def regenerate_for_definition( # child-presence counterpart to ``regenerate_for_definition``. The # presence business layer that lands in Feature 09 MUST call # ``regenerate_for_child`` after EVERY presence-state write, so future -# instances track a child's changed presence: set_presence_pattern -# (including clearing/setting an empty pattern), deleting/clearing the -# presence schedule (PresenceSchedulesDao.delete), -# create_presence_override, and delete_presence_override. A schedule -# delete changes presence state too (the child becomes always-present), -# so it must regenerate just like the others. It is deliberately NOT +# instances track a child's changed presence: create_presence_pattern, +# update_presence_pattern, delete_presence_pattern, +# create_presence_override, and delete_presence_override. A pattern +# delete changes presence state too, so it must regenerate just like +# the others. It is deliberately NOT # wired into ``dao_presence.py``: the DAO layer stays pure storage (no # business rules), and no presence business layer exists yet to host the # call — see the module docstring note above. @@ -385,9 +391,8 @@ async def regenerate_for_child( """Regenerate a child's future instances after a presence change. The Feature 09 presence services call this after every - presence-state write — set_presence_pattern (including clearing or - setting an empty pattern), deleting/clearing the presence schedule - (:meth:`~.dao_presence.PresenceSchedulesDao.delete`), + presence-state write — create_presence_pattern, + update_presence_pattern, delete_presence_pattern, create_presence_override, and delete_presence_override — so a child's future instances track its new presence: the child's open instances at or after today are deleted across ALL its definitions @@ -481,12 +486,12 @@ async def count_removed_by_override( start_date, end_date = start.isoformat(), end.isoformat() child_id = override.child_id - snapshots, schedules_records, overrides_records = await ( + snapshots, patterns_records, overrides_records = await ( load_materialization_input(database, start_date, end_date) ) current = _generation_preview( snapshots, - _build_engine(schedules_records, overrides_records), + _build_engine(patterns_records, overrides_records), start, end, child_filter={child_id}, @@ -495,7 +500,7 @@ async def count_removed_by_override( _generation_preview( snapshots, _build_engine( - schedules_records, + patterns_records, overrides_records, extra_overrides=(override,), ), diff --git a/custom_components/nestquest/core/migrations.py b/custom_components/nestquest/core/migrations.py index fba7c50..9ff957a 100644 --- a/custom_components/nestquest/core/migrations.py +++ b/custom_components/nestquest/core/migrations.py @@ -31,7 +31,11 @@ widened (definition_id, child_id, due_date, window) key, and migration column. Migration 7 adds the generic ``nestquest_meta_state`` key/value table for integration-owned bookkeeping (Feature 11's missed-sweep watermark). Migration 8 adds the ``skip_on_away`` -column to ``quest_definitions`` (Feature 22), defaulting to 1. Each +column to ``quest_definitions`` (Feature 22), defaulting to 1. +Migration 9 replaces the one-per-child ``presence_schedules`` table +with ``presence_patterns`` (zero or more patterns per child), copying +every schedule across as a ``home`` pattern and dropping the old table +— see :data:`MIGRATION_9_PRESENCE_PATTERNS` for the rollback note. Each callable migration runs inside its own transaction together with its version stamp, so a crash mid-step rolls the statements and the stamp back together. @@ -85,6 +89,7 @@ from .schema import ( SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, SCHEMA_V7_META_STATE_DDL, + SCHEMA_V9_PRESENCE_PATTERNS_DDL, ) LOGGER = logging.getLogger(__name__) @@ -116,9 +121,76 @@ VERSION_TABLE_DDL = f""" ) """ +#: The retired ``presence_schedules`` DDL (one schedule per child), +#: frozen here verbatim: migration 1 shipped creating this table and +#: applied migrations are never edited, so a fresh file still walks +#: through it and migration 9 retires it. :mod:`.schema` no longer +#: declares it. +_MIGRATION_1_PRESENCE_SCHEDULES_DDL = """ + CREATE TABLE IF NOT EXISTS presence_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL UNIQUE REFERENCES children(id), + cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4), + anchor_date TEXT NOT NULL, + pattern TEXT NOT NULL CHECK ( + NOT pattern GLOB '*[^0-6,|]*' + AND NOT pattern GLOB '*[0-6][0-6]*' + AND NOT pattern GLOB '*,,*' + AND NOT pattern GLOB '*,|*' + AND NOT pattern GLOB '*|,*' + AND NOT pattern GLOB ',*' + AND NOT pattern GLOB '*,' + AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*' + AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks + ) + ) + """ + #: One-based index into :data:`MIGRATIONS`: entry ``n`` upgrades -#: version ``n-1`` to version ``n``. -MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS) +#: version ``n-1`` to version ``n``. Migration 1 is the v1 DDL as it +#: shipped: the current tables, except that it still creates the +#: retired ``presence_schedules`` table in place of +#: ``presence_patterns`` (which migration 9 creates). +MIGRATION_1_V1_DDL: list[str] = [ + _MIGRATION_1_PRESENCE_SCHEDULES_DDL + if sql in SCHEMA_V9_PRESENCE_PATTERNS_DDL + else sql + for sql in SCHEMA_V1_STATEMENTS +] + +#: The label every migrated ``presence_schedules`` row carries as a +#: ``presence_patterns`` row. +MIGRATED_PRESENCE_PATTERN_NAME = "Home schedule" + +#: Migration 9 (schema 9): multiple presence patterns per child. +#: Creates ``presence_patterns``, copies EVERY ``presence_schedules`` +#: row into it as a ``home`` pattern named +#: :data:`MIGRATED_PRESENCE_PATTERN_NAME` — ``cycle_length_weeks``, +#: ``anchor_date`` and ``pattern`` verbatim, the row id kept — and +#: drops ``presence_schedules``. A ``home`` pattern with no other +#: pattern beside it answers exactly as the old schedule did (the +#: engine treats a date no home pattern covers as a day away), so every +#: child's presence is unchanged. A plain statement list: the runner +#: applies it and the version stamp in ONE transaction, so a failure +#: rolls the copy and the drop back together. Dropping a table nothing +#: references needs no ``foreign_keys = OFF`` bracket. +#: +#: Rollback: the drop is one-way. A 0.7.1 build refuses a version-9 +#: file ("newer than this integration understands") and could not read +#: ``presence_patterns`` anyway, so take a copy of the SQLite file (or +#: note the Litestream generation) BEFORE the first start of the release +#: carrying migration 9; rolling back to 0.7.1 means restoring that +#: pre-migration copy, which still holds the single-schedule rows. +#: Pattern edits made after the upgrade are not in that copy. +MIGRATION_9_PRESENCE_PATTERNS: list[str] = [ + *SCHEMA_V9_PRESENCE_PATTERNS_DDL, + "INSERT INTO presence_patterns (id, child_id, name, kind, " + "cycle_length_weeks, anchor_date, pattern) " + f"SELECT id, child_id, '{MIGRATED_PRESENCE_PATTERN_NAME}', 'home', " + "cycle_length_weeks, anchor_date, pattern " + "FROM presence_schedules ORDER BY id", + "DROP TABLE presence_schedules", +] #: The assignees table DDL, shared with migration 1 via the schema #: module so a rebuilt and a freshly created table cannot drift. @@ -450,6 +522,9 @@ MIGRATIONS: Sequence[MigrationStep] = [ # quest_definitions — an additive column defaulting to 1, so every # pre-existing definition keeps today's skip-when-absent behaviour. _add_skip_on_away, + # Migration 9 (schema 9): presence_schedules -> presence_patterns, + # each schedule copied across as a 'home' pattern. + MIGRATION_9_PRESENCE_PATTERNS, ] diff --git a/custom_components/nestquest/core/presence.py b/custom_components/nestquest/core/presence.py index 465c105..cca4b2a 100644 --- a/custom_components/nestquest/core/presence.py +++ b/custom_components/nestquest/core/presence.py @@ -5,21 +5,24 @@ timezone logic — it operates on plain calendar dates only (feature guardrail). The later engine (cycle position, ``is_present``, overrides, previews) builds directly on this model. -A :class:`PresenceSchedule` answers "on which weekdays of which cycle -week is this child at this house?" for an N-week repeating custody -pattern. Two of the household's three children follow a two-week -rotation; a child with NO schedule row is present every day (that -default lives in the engine, not here). +A :class:`PresencePattern` answers "on which weekdays of which cycle +week does this rule apply to this child?" for an N-week repeating +custody rule, and its ``kind`` says what the rule claims on those +days: ``home`` (the child is at this house) or ``away``. A child +carries ZERO OR MORE patterns (schema 9), e.g. "away every Thursday +and Friday" plus "away every other weekend"; a child with NO pattern +is present every day (that default, and how patterns combine, lives in +the engine, not here). Pattern encoding: the model's canonical serialization is the SAME pipe-separated CSV the database stores (see schema.py's -``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a -comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday, -e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An -empty segment means absent every day of that week. Encoding sorts -each segment's weekdays and uses the exact segment order, so -``decode(encode(schedule))`` round-trips losslessly and two schedules -with equal patterns produce equal strings. +``presence_patterns.pattern``): segment ``i`` (week index ``i``) is a +comma-separated list of the weekdays the pattern covers, 0 = Monday .. +6 = Sunday, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in +week 1. An empty segment means the pattern covers no day of that +week. Encoding sorts each segment's weekdays and uses the exact +segment order, so ``decode(encode(pattern))`` round-trips losslessly +and two patterns with equal weekday sets produce equal strings. Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is computed from anchor-date arithmetic ONLY — never ISO week numbers or @@ -28,7 +31,9 @@ week parity (D-004): 53-week years silently invert parity on January that the anchor is a strict calendar date. Validation policy: construction is total — every reject happens in -``__init__`` so no invalid schedule can ever exist: +``__init__`` so no invalid pattern can ever exist: +- ``name`` must be a string that is not blank; it is stored trimmed. +- ``kind`` must be ``'home'`` or ``'away'``. - ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches the database CHECK (schema.py) and keeps anchor arithmetic and the encoded pattern human-readable; revisit both together if a longer @@ -49,6 +54,10 @@ from types import MappingProxyType #: the pattern human-readable and the anchor arithmetic small). MAX_CYCLE_LENGTH_WEEKS = 4 +#: The pattern kinds the schema's CHECK allows: what a covering +#: pattern claims about the child on that date. +PATTERN_KINDS = ("home", "away") + _DATE_FORMAT = "%Y-%m-%d" @@ -126,6 +135,25 @@ def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]: return frozenset(members) +def _validate_name(value: object) -> str: + """Reject non-string and blank pattern names; return it trimmed.""" + if not isinstance(value, str): + raise ValueError(f"name must be a string, got {value!r}") + trimmed = value.strip() + if not trimmed: + raise ValueError("name must not be empty") + return trimmed + + +def _validate_kind(value: object) -> str: + """Reject any kind other than the schema's ``home`` / ``away``.""" + if not isinstance(value, str) or value not in PATTERN_KINDS: + raise ValueError( + f"kind must be one of {', '.join(PATTERN_KINDS)}, got {value!r}" + ) + return value + + def _validate_cycle_length(value: object) -> int: """Reject non-int and out-of-range cycle lengths (bools included).""" if isinstance(value, bool) or not isinstance(value, int): @@ -148,17 +176,19 @@ def _validate_child_id(value: object) -> int: @dataclass(frozen=True, init=False) -class PresenceSchedule: - """One child's N-week repeating presence pattern. +class PresencePattern: + """One of a child's N-week repeating presence rules. ``pattern`` maps each week index of the cycle (``0`` .. ``n-1``) - to the set of present weekdays (ints, Monday=0). A week with an - empty set means the child is absent every day of that week — which - is distinct from having no schedule at all (the engine treats a - missing schedule as present every day). + to the set of weekdays the rule covers (ints, Monday=0); ``kind`` + is what the rule claims on a covered date (``home`` or ``away``). + A week with an empty set covers no day of that week. How several + patterns combine is the engine's job (:class:`PresenceEngine`). """ child_id: int + name: str + kind: str cycle_length_weeks: int anchor_date: datetime.date pattern: MappingProxyType # week index -> frozenset of weekdays @@ -166,11 +196,15 @@ class PresenceSchedule: def __init__( self, child_id: int, + name: str, + kind: str, cycle_length_weeks: int, anchor_date: object, pattern: dict[int, object], ) -> None: object.__setattr__(self, "child_id", _validate_child_id(child_id)) + object.__setattr__(self, "name", _validate_name(name)) + object.__setattr__(self, "kind", _validate_kind(kind)) cycle = _validate_cycle_length(cycle_length_weeks) object.__setattr__(self, "cycle_length_weeks", cycle) object.__setattr__( @@ -178,7 +212,7 @@ class PresenceSchedule: ) if not isinstance(pattern, dict): raise ValueError( - "pattern must map week index to a set of present " + "pattern must map week index to a set of covered " f"weekdays, got {pattern!r}" ) normalised: dict[int, frozenset[int]] = {} @@ -204,9 +238,9 @@ class PresenceSchedule: """Serialize to the database's pipe-separated CSV shape. Segments appear in week order; each segment is the week's - present weekdays as a sorted comma-separated list (an empty set - encodes as an empty segment). Two schedules with equal - patterns encode identically. + covered weekdays as a sorted comma-separated list (an empty set + encodes as an empty segment). Two patterns with equal weekday + sets encode identically. """ segments = [ ",".join(str(day) for day in sorted(self.pattern[week])) @@ -218,10 +252,12 @@ class PresenceSchedule: def decode( cls, child_id: int, + name: str, + kind: str, anchor_date: object, encoded: str, - ) -> "PresenceSchedule": - """Build a schedule from its encoded pattern. + ) -> "PresencePattern": + """Build a pattern from its encoded weekday sets. The cycle length is derived from the segment count, mirroring the database's segment-count CHECK. Decoding is strict: wrong @@ -248,7 +284,15 @@ class PresenceSchedule: ) weekdays.add(int(part)) pattern[week] = weekdays - return cls(child_id, len(segments), anchor_date, pattern) + return cls(child_id, name, kind, len(segments), anchor_date, pattern) + + def covers(self, date: datetime.date) -> bool: + """Return True when ``date``'s weekday is in its cycle week's set. + + The cycle week comes from :func:`cycle_week_index` — anchor-date + arithmetic only, never ISO week numbers or parity (D-004). + """ + return date.weekday() in self.pattern[cycle_week_index(self, date)] @dataclass(frozen=True, init=False) @@ -301,7 +345,7 @@ class PresenceOverride: def cycle_week_index( - schedule: PresenceSchedule, target_date: object + pattern: PresencePattern, target_date: object ) -> int: """Return the cycle week index ``target_date`` falls in. @@ -315,14 +359,14 @@ def cycle_week_index( valid week index. """ target = _parse_date(target_date, "target_date") - days = (target - schedule.anchor_date).days - return (days // 7) % schedule.cycle_length_weeks + days = (target - pattern.anchor_date).days + return (days // 7) % pattern.cycle_length_weeks #: Search cap for :meth:`PresenceEngine.next_present_dates`: two full -#: years of daily checks. A child absent every day (an all-absent -#: pattern with a blocking override) would otherwise loop forever; the -#: cap bounds the scan and surfaces the situation as an error instead. +#: years of daily checks. A child absent every day (an away pattern +#: covering every weekday) would otherwise loop forever; the cap bounds +#: the scan and surfaces the situation as an error instead. MAX_PREVIEW_SCAN_DAYS = 366 * 2 @@ -330,40 +374,45 @@ class PresenceEngine: """Answers "is this child at this house on this date?". Pure and side-effect-free: built from an immutable snapshot of the - children's schedules (``child_id -> PresenceSchedule``), it never - touches the database — callers build a snapshot from the DAO layer - and pass it in. A child with NO schedule entry is present every - day (the third household child's case); a child WITH a schedule is - present exactly when the date's weekday is in that schedule's - pattern for the date's cycle week index (anchor-date arithmetic — - :func:`cycle_week_index`, never ISO week parity). + children's patterns (``child_id -> list of PresencePattern``), it + never touches the database — callers build a snapshot from the DAO + layer and pass it in. A child with NO pattern is present every day + (the third household child's case). Evaluation order, first match + wins — see :meth:`is_present`. Pattern coverage is anchor-date + arithmetic (:func:`cycle_week_index`, never ISO week parity). """ def __init__( self, - schedules: dict[int, PresenceSchedule], + patterns: dict[int, list[PresencePattern]], overrides: dict[int, list[PresenceOverride]] | None = None, ) -> None: - if not isinstance(schedules, dict): + if not isinstance(patterns, dict): raise ValueError( - f"schedules must map child_id to PresenceSchedule, got " - f"{schedules!r}" + "patterns must map child_id to a list of PresencePattern, " + f"got {patterns!r}" ) - for child_id, schedule in schedules.items(): + for child_id, entries in patterns.items(): # Validate the KEY as a model child id too: True and 1.0 - # compare equal to 1, so {True: schedule(1)} would be + # compare equal to 1, so {True: [pattern(1)]} would be # accepted and then behave differently on lookup. _validate_child_id(child_id) - if not isinstance(schedule, PresenceSchedule): + if not isinstance(entries, list): raise ValueError( - f"schedules[{child_id!r}] must be a PresenceSchedule, " - f"got {schedule!r}" - ) - if schedule.child_id != child_id: - raise ValueError( - f"schedules is keyed {child_id!r} but holds the " - f"schedule of child {schedule.child_id}" + f"patterns[{child_id!r}] must be a list of " + f"PresencePattern, got {entries!r}" ) + for entry in entries: + if not isinstance(entry, PresencePattern): + raise ValueError( + f"patterns[{child_id!r}] entries must be " + f"PresencePattern, got {entry!r}" + ) + if entry.child_id != child_id: + raise ValueError( + f"patterns is keyed {child_id!r} but holds a " + f"pattern of child {entry.child_id}" + ) if overrides is None: overrides = {} if not isinstance(overrides, dict): @@ -391,11 +440,17 @@ class PresenceEngine: ) # Read-only snapshot: reassignment is refused and no mutator # exists, so a built engine answers consistently for its - # lifetime. The overrides lists are copied to immutable - # tuples — a shallow mapping copy alone would leave caller- - # owned lists reachable through _overrides, and mutating one - # later would change is_present results. - object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) + # lifetime. The patterns and overrides lists are copied to + # immutable tuples — a shallow mapping copy alone would leave + # caller-owned lists reachable, and mutating one later would + # change is_present results. + object.__setattr__( + self, + "_patterns", + MappingProxyType( + {child_id: tuple(entries) for child_id, entries in patterns.items()} + ), + ) object.__setattr__( self, "_overrides", @@ -407,12 +462,22 @@ class PresenceEngine: def is_present(self, child_id: object, date: object) -> bool: """Return whether ``child_id`` is present on ``date``. - Overrides take precedence over the repeating pattern: when an - override covers the date, its flag IS the answer — including - for a child with no schedule at all. The DAO layer guarantees - a child's overrides never overlap; if a snapshot still carries - two covering the same date, the one with the LATEST start date - wins (deterministic, and matches "the most specific swap wins"). + Ordered, first match wins: + + 1. An override covering the date — its flag IS the answer, + including for a child with no pattern at all. The DAO layer + guarantees a child's overrides never overlap; if a snapshot + still carries two covering the same date, the one with the + LATEST start date wins (deterministic, and matches "the most + specific swap wins"). + 2. Any ``away`` pattern covering the date — absent. + 3. Any ``home`` pattern covering the date — present. + 4. Otherwise: absent when the child has at least one ``home`` + pattern (a home pattern lists the days the child IS here, so + an uncovered date is a day away — the retired single-schedule + semantics, which migration 9 carries across as ``home`` + patterns unchanged), else present (no pattern, or ``away`` + patterns only: the household default). """ if isinstance(child_id, bool) or not isinstance(child_id, int): raise ValueError(f"child_id must be an integer, got {child_id!r}") @@ -424,12 +489,16 @@ class PresenceEngine: if covering: winner = max(covering, key=lambda entry: entry.start_date) return winner.is_present - schedule = self._schedules.get(child_id) - if schedule is None: - # No schedule at all: present every day (household default). + patterns = self._patterns.get(child_id) or () + if any( + pattern.kind == "away" and pattern.covers(target) + for pattern in patterns + ): + return False + homes = [pattern for pattern in patterns if pattern.kind == "home"] + if any(pattern.covers(target) for pattern in homes): return True - week = cycle_week_index(schedule, target) - return target.weekday() in schedule.pattern[week] + return not homes def next_present_dates( self, @@ -442,7 +511,7 @@ class PresenceEngine: Walks forward from ``start_date`` (INCLUSIVE — a parent previewing "starting Monday" expects Monday itself when the child is present) checking :meth:`is_present` for every day, - so overrides and the pattern are honoured identically. The + so overrides and the patterns are honoured identically. The admin Schedule tab renders this preview so a parent can confirm a custody pattern is right before saving it. diff --git a/custom_components/nestquest/core/presence_management.py b/custom_components/nestquest/core/presence_management.py index fea6189..28caa07 100644 --- a/custom_components/nestquest/core/presence_management.py +++ b/custom_components/nestquest/core/presence_management.py @@ -2,11 +2,11 @@ Sits between the write callers (the Feature 09 HA service handlers and the API admin plane's presence routes) and the typed presence DAOs plus -the pure presence model: callers get the model validation, the upsert / -create / delete operations and the regeneration side effect here, and +the pure presence model: callers get the model validation, the create / +update / delete operations and the regeneration side effect here, and never touch the DAO or SQL directly. This is the ONE implementation of these business rules — every caller constructs the same -:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride` +:class:`~.presence.PresencePattern` / :class:`~.presence.PresenceOverride` models (whose constructors are total, so no re-validation happens in a handler) and gets the same post-write :func:`~.materialize.regenerate_for_child`, so a child's future quest @@ -15,16 +15,16 @@ write. Like the model it is Home-Assistant-free: ``today`` is a plain calendar date threaded through to the regeneration, defaulting to the host clock there. -Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence -schedule — setting again REPLACES the existing row rather than -duplicating it. Overrides are deletable by design (children are not — -the children layer owns that guardrail). Deleting a nonexistent -override raises ValueError prefixed ``override_id:`` so a caller can -distinguish "unknown id" from a rejected argument the same way the +Pattern policy (schema 9): a child has ZERO OR MORE presence patterns, +each created, edited and deleted by its own id. Patterns and overrides +are deletable by design (children are not — the children layer owns +that guardrail). An unknown pattern or override id raises ValueError +prefixed ``pattern_id:`` / ``override_id:`` so a caller can distinguish +"unknown id" from a rejected argument the same way the quest-definitions layer prefixes ``definition_id:``. The admin plane's presence READS go through here too -(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a +(:func:`list_presence_patterns`, :func:`list_presence_overrides`), so a reader never touches the DAO directly either. """ from __future__ import annotations @@ -35,11 +35,13 @@ from .dao_children import ChildrenDao from .dao_presence import ( PresenceOverrideRecord, PresenceOverridesDao, - PresenceSchedulesDao, + PresencePatternRecord, + PresencePatternsDao, ) +from .dao_rules import _UNSET from .db import NestQuestDatabase from .materialize import count_removed_by_override, regenerate_for_child -from .presence import PresenceOverride, PresenceSchedule +from .presence import PresenceOverride, PresencePattern def _validate_override_id(value: object) -> int: @@ -49,49 +51,159 @@ def _validate_override_id(value: object) -> int: return value -async def set_presence_schedule( +def _validate_pattern_id(value: object) -> int: + """Reject non-int pattern ids (bools would silently address id 1).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"pattern_id must be an integer, got {value!r}") + return value + + +async def _get_pattern_or_raise( + dao: PresencePatternsDao, pattern_id: object +) -> PresencePatternRecord: + """Return the stored pattern; an unknown id raises ``pattern_id:``.""" + record = await dao.get(_validate_pattern_id(pattern_id)) + if record is None: + raise ValueError( + f"pattern_id: presence pattern {pattern_id} does not exist" + ) + return record + + +def decode_pattern_record(record: PresencePatternRecord) -> PresencePattern: + """Decode one stored pattern row back through the model.""" + return PresencePattern.decode( + record.child_id, + record.name, + record.kind, + record.anchor_date, + record.pattern, + ) + + +async def create_presence_pattern( database: NestQuestDatabase, child_id: int, + name: str, + kind: str, cycle_length_weeks: int, anchor_date: object, pattern: dict[int, object], *, today: datetime.date | None = None, horizon_days: int | None = None, -) -> PresenceSchedule: - """Set (upsert) a child's repeating presence schedule. +) -> PresencePatternRecord: + """Add one repeating presence pattern to a child. - The :class:`~.presence.PresenceSchedule` model validates at - construction — cycle length 1..4, a strict calendar anchor date, - and a pattern covering week indices 0..n-1 with weekday sets — so - no invalid schedule reaches the DAO. The encoded pattern is - UPSERTed by child (a second set REPLACES the child's existing - schedule; the schema's UNIQUE(child_id) makes duplication - unrepresentable), then :func:`~.materialize.regenerate_for_child` + The :class:`~.presence.PresencePattern` model validates at + construction — a non-blank name, ``kind`` ``home``/``away``, cycle + length 1..4, a strict calendar anchor date, and a pattern covering + week indices 0..n-1 with weekday sets — so no invalid pattern + reaches the DAO. The encoded pattern is inserted beside the + child's other patterns, then :func:`~.materialize.regenerate_for_child` re-materializes the child's future instances against the new presence. ``today``/``horizon_days`` thread through to that regeneration; raising ValueError names what to fix (an unknown child reads ``child N does not exist``). - Returns the STORED schedule decoded back through the model — the - round-trip proves the encoded pattern the database now holds is - exactly the one the caller asked for. + Returns the STORED record WITH its ``id`` — the handle + :func:`update_presence_pattern` and :func:`delete_presence_pattern` + take. """ - schedule = PresenceSchedule( - child_id, cycle_length_weeks, anchor_date, pattern + model = PresencePattern( + child_id, name, kind, cycle_length_weeks, anchor_date, pattern ) - record = await PresenceSchedulesDao(database).upsert_by_child( - schedule.child_id, - schedule.cycle_length_weeks, - schedule.anchor_date.isoformat(), - schedule.encode(), + record = await PresencePatternsDao(database).create( + model.child_id, + model.name, + model.kind, + model.cycle_length_weeks, + model.anchor_date.isoformat(), + model.encode(), ) await regenerate_for_child( - database, schedule.child_id, today=today, horizon_days=horizon_days + database, model.child_id, today=today, horizon_days=horizon_days ) - return PresenceSchedule.decode( - record.child_id, record.anchor_date, record.pattern + return record + + +async def update_presence_pattern( + database: NestQuestDatabase, + pattern_id: int, + *, + name: str | object = _UNSET, + kind: str | object = _UNSET, + cycle_length_weeks: int | object = _UNSET, + anchor_date: object = _UNSET, + pattern: dict[int, object] | object = _UNSET, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresencePatternRecord: + """Edit one presence pattern; only supplied fields change. + + The pattern is looked up FIRST (an unknown id raises ValueError + prefixed ``pattern_id:``), the supplied fields are laid over the + stored ones and the WHOLE result is rebuilt through the + :class:`~.presence.PresencePattern` model — so a partial edit is + validated exactly as a create is (changing ``cycle_length_weeks`` + without a matching ``pattern`` is rejected), and an explicit None + is rejected rather than ignored. The pattern stays with its child; + the child's future instances are regenerated afterwards. + """ + dao = PresencePatternsDao(database) + stored = decode_pattern_record(await _get_pattern_or_raise(dao, pattern_id)) + model = PresencePattern( + stored.child_id, + stored.name if name is _UNSET else name, + stored.kind if kind is _UNSET else kind, + ( + stored.cycle_length_weeks + if cycle_length_weeks is _UNSET + else cycle_length_weeks + ), + stored.anchor_date if anchor_date is _UNSET else anchor_date, + dict(stored.pattern) if pattern is _UNSET else pattern, ) + record = await dao.update( + pattern_id, + model.name, + model.kind, + model.cycle_length_weeks, + model.anchor_date.isoformat(), + model.encode(), + ) + if record is None: + raise ValueError( + f"pattern_id: presence pattern {pattern_id} does not exist" + ) + await regenerate_for_child( + database, model.child_id, today=today, horizon_days=horizon_days + ) + return record + + +async def delete_presence_pattern( + database: NestQuestDatabase, + pattern_id: int, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresencePatternRecord: + """Delete one presence pattern by id and return it as it was stored. + + The pattern is looked up FIRST and an unknown id raises ValueError + prefixed ``pattern_id:`` BEFORE any delete, so a mistyped id can + never be silently swallowed by a no-op. The deletion is followed + by :func:`~.materialize.regenerate_for_child` for the pattern's own + child — the id decides the child, never a caller-supplied one. + """ + dao = PresencePatternsDao(database) + record = await _get_pattern_or_raise(dao, pattern_id) + await dao.delete(record.id) + await regenerate_for_child( + database, record.child_id, today=today, horizon_days=horizon_days + ) + return record async def create_presence_override( @@ -171,21 +283,16 @@ async def preview_presence_override_consequence( ) -async def get_presence_schedule( +async def list_presence_patterns( database: NestQuestDatabase, child_id: int -) -> PresenceSchedule | None: - """Return the child's presence schedule decoded, or None when unset. +) -> list[PresencePatternRecord]: + """Return the child's presence patterns ordered by id. - None means the child exists and has no schedule row — present + An empty list means the child exists and has no pattern — present every day (Feature 05). An unknown child raises ValueError (``child N does not exist``) rather than reading as present. """ - record = await PresenceSchedulesDao(database).get_by_child(child_id) - if record is None: - return None - return PresenceSchedule.decode( - record.child_id, record.anchor_date, record.pattern - ) + return await PresencePatternsDao(database).list_by_child(child_id) async def list_presence_overrides( diff --git a/custom_components/nestquest/core/schema.py b/custom_components/nestquest/core/schema.py index 880ae36..a2daffe 100644 --- a/custom_components/nestquest/core/schema.py +++ b/custom_components/nestquest/core/schema.py @@ -43,18 +43,19 @@ rule or a monthly rule could later be pinned to a month) nor extra fields on weekly/monthly/yearly rules generally, so the CHECKs only reject what the rule engine definitively cannot express now. -Presence pattern format: ``presence_schedules.pattern`` encodes the +Presence pattern format: ``presence_patterns.pattern`` encodes the whole N-week repeating cycle in one TEXT column as pipe-separated segments, one per week of the cycle: segment ``i`` is a ``weekday_set`` CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``) for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in -week 1. An empty segment means the child is absent every day of that -week; a wholly empty pattern with ``cycle_length_weeks = 1`` means -absent every day of every week, which is distinct from having no -schedule row at all (a child with no schedule is present every day, per -the feature guardrails). Chosen over JSON or per-week rows to stay -consistent with the CSV ``weekday_set`` convention and fully checkable -by plain CHECKs. DB-level validation is pure CHECK, no triggers or +week 1. An empty segment means the pattern covers no day of that week. +A child carries zero or more patterns, each ``kind`` ``home`` or +``away``; a child with no pattern row at all is present every day, per +the feature guardrails (how patterns combine is the presence engine's +job). The encoding and its CHECKs are carried over verbatim from the +retired ``presence_schedules`` table (schema 9). Chosen over JSON or +per-week rows to stay consistent with the CSV ``weekday_set`` +convention and fully checkable by plain CHECKs. DB-level validation is pure CHECK, no triggers or custom functions: char-class GLOBs reject every character outside digits 0-6, comma and pipe; adjacency GLOBs reject empty elements (double comma, comma next to a pipe, leading/trailing comma) and @@ -68,7 +69,8 @@ weekday_set this is not full CSV-set normalisation (duplicates like the engine treats the segment as a set. A one-row-per-``week_index`` normalization was considered and rejected: the flat single-column encoding keeps the done-condition's table shape -and proved fully checkable with plain CHECKs. +and proved fully checkable with plain CHECKs. ``name`` is a short +label and carries no CHECK; the model rejects a blank one. ``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the common case, and a small cap keeps anchor arithmetic and the encoded @@ -288,11 +290,19 @@ SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [ """, ] -SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [ - f""" - CREATE TABLE IF NOT EXISTS presence_schedules ( +#: ``presence_patterns`` (schema version 9): zero or more repeating +#: presence rules per child, replacing the retired one-per-child +#: ``presence_schedules`` table. ``child_id`` is deliberately NOT +#: unique; ``kind`` is what a covering pattern claims (``home`` or +#: ``away``); ``pattern`` keeps the retired table's encoding and CHECKs +#: verbatim. +SCHEMA_V9_PRESENCE_PATTERNS_DDL: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS presence_patterns ( id INTEGER PRIMARY KEY AUTOINCREMENT, - child_id INTEGER NOT NULL UNIQUE REFERENCES children(id), + child_id INTEGER NOT NULL REFERENCES children(id), + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('home', 'away')), cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4), anchor_date TEXT NOT NULL, pattern TEXT NOT NULL CHECK ( @@ -407,7 +417,7 @@ SCHEMA_V1_STATEMENTS: list[str] = [ *SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, - *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, + *SCHEMA_V9_PRESENCE_PATTERNS_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py index 0236960..662a2f9 100644 --- a/custom_components/nestquest/core/snapshot.py +++ b/custom_components/nestquest/core/snapshot.py @@ -39,14 +39,14 @@ from typing import Any from .completion import derive_state from .dao_children import ChildrenDao from .dao_instances import CompletionEventsDao, QuestInstancesDao -from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .dao_presence import PresenceOverridesDao, PresencePatternsDao from .dao_rules import QuestDefinitionsDao from .db import NestQuestDatabase from .presence import ( MAX_PREVIEW_SCAN_DAYS, PresenceEngine, PresenceOverride, - PresenceSchedule, + PresencePattern, ) from .settings import NestQuestSettings @@ -98,20 +98,26 @@ class NestQuestSnapshot: def _cycle_day( - schedules: dict[int, PresenceSchedule], children, today: datetime.date + patterns: dict[int, list[PresencePattern]], + children, + today: datetime.date, ) -> int: - """Return today's 1-based day in the first scheduled child's cycle. + """Return today's 1-based day in the first patterned child's cycle. - Anchor-date arithmetic ONLY (D-004): the day offset is computed from - the schedule's own anchor, never ISO week numbers or parity, so a - 53-week year cannot silently invert the cycle. + The cycle is that child's FIRST pattern (lowest id) — one number + for the household's cycle-day sensor even when a child carries + several patterns. Anchor-date arithmetic ONLY (D-004): the day + offset is computed from the pattern's own anchor, never ISO week + numbers or parity, so a 53-week year cannot silently invert the + cycle. """ for child in children: - schedule = schedules.get(child.id) - if schedule is None: + entries = patterns.get(child.id) + if not entries: continue - days = (today - schedule.anchor_date).days - cycle_length = schedule.cycle_length_weeks * 7 + pattern = entries[0] + days = (today - pattern.anchor_date).days + cycle_length = pattern.cycle_length_weeks * 7 return (days % cycle_length) + 1 return 0 @@ -191,16 +197,21 @@ async def build_snapshot( children = await ChildrenDao(database).list_active() - schedules: dict[int, PresenceSchedule] = {} + patterns: dict[int, list[PresencePattern]] = {} overrides: dict[int, list[PresenceOverride]] = {} for child in children: - schedule = await PresenceSchedulesDao(database).get_by_child( - child.id - ) - if schedule is not None: - schedules[child.id] = PresenceSchedule.decode( - child.id, schedule.anchor_date, schedule.pattern - ) + records = await PresencePatternsDao(database).list_by_child(child.id) + if records: + patterns[child.id] = [ + PresencePattern.decode( + record.child_id, + record.name, + record.kind, + record.anchor_date, + record.pattern, + ) + for record in records + ] window_end = today + datetime.timedelta( days=MAX_PREVIEW_SCAN_DAYS ) @@ -222,7 +233,7 @@ async def build_snapshot( ) for record in ranged ] - engine = PresenceEngine(schedules, overrides) + engine = PresenceEngine(patterns, overrides) instances_dao = QuestInstancesDao(database) events_dao = CompletionEventsDao(database) @@ -302,5 +313,5 @@ async def build_snapshot( return NestQuestSnapshot( today_iso=today_iso, children=tuple(snapshots), - cycle_day=_cycle_day(schedules, children, today), + cycle_day=_cycle_day(patterns, children, today), ) diff --git a/tests/test_api_admin_presence.py b/tests/test_api_admin_presence.py index e86b878..5ce5590 100644 --- a/tests/test_api_admin_presence.py +++ b/tests/test_api_admin_presence.py @@ -1,4 +1,4 @@ -"""Admin-plane presence route tests (task e4a9d31b). +"""Admin-plane presence route tests (task e4a9d31b; schema 9 patterns). These tests exercise the admin plane's presence routes against the ASGI app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness @@ -11,31 +11,38 @@ read through the SAME core copy the app uses (``nestquest_core.*``; see the two-copy caveat in api/nestquest_core.py): - Authentication runs through the router's ONE ``require_admin`` - dependency: an admin JWT passes, a JWT without the group is 403, an - absent credential is 401 — and nothing is written. -- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the - first set persists the schedule, setting again REPLACES it — one - schedule row per child, never a duplicate. + dependency on EVERY presence route (the four pattern routes and the + override routes): an admin JWT passes, a JWT without the group or the + panel service token is 403, an absent credential is 401 — and + nothing is written. +- ``POST /api/v1/admin/children/{id}/presence-patterns`` ADDS a + pattern: a child has zero or more, each create inserts a new row + (never replacing an earlier one). ``PATCH + /api/v1/admin/presence-patterns/{id}`` changes only the supplied + fields; ``DELETE`` removes the one pattern. The removed one-per-child + ``/presence-schedule`` route no longer exists. - ``POST /api/v1/admin/presence-overrides`` creates the override (its ``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}`` removes it again. - The child's RESOLVED presence changes accordingly: the pure :class:`~nestquest_core.presence.PresenceEngine` built from the - stored rows answers differently before/after each write — a schedule - restricts the child's present weekdays, an override beats the pattern - for its range, and deleting the override restores the pattern's - answer. + stored rows answers differently before/after each write — a home + pattern restricts the child's present weekdays, an away pattern beats + a home one, an override beats every pattern for its range, and + deleting the override (or the pattern) restores the earlier answer. - The regeneration side effect lives in the core layer the route - calls: setting an all-absent schedule through the route deletes the - child's future open quest instances and they are NOT rebuilt while - the child is absent every day — the same proof the HA service test - makes, through the API instead. + calls: adding an all-absent home pattern through the route deletes + the child's future open quest instances and they are NOT rebuilt + while the child is absent every day; deleting that pattern rebuilds + them — the same proof the HA service test makes, through the API. Error mapping (documented in api/routes_admin.py): a core ValueError -for a non-existent child (schedule or override create) or override -(delete) is 404; every other core ValueError — a rejected schedule -(cycle length, pattern coverage, anchor date) or override (malformed or -inverted dates, an overlapping range) — is 422, the same failure class +for a non-existent child (pattern or override create) is 404 "Child not +found", a non-existent pattern (edit/delete) 404 "Presence pattern not +found", a non-existent override (delete) 404; every other core +ValueError — a rejected pattern (name, kind, cycle length, pattern +coverage, anchor date) or override (malformed or inverted dates, an +overlapping range) — is 422 naming the field, the same failure class FastAPI reports for malformed bodies. """ from __future__ import annotations @@ -64,24 +71,54 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances") _materialize = importlib.import_module("nestquest_core.materialize") _presence = importlib.import_module("nestquest_core.presence") -#: A Monday anchoring every schedule: cycle week 0 starts here. +#: A Monday anchoring every pattern: cycle week 0 starts here. ANCHOR_MONDAY = "2026-01-05" -#: The Tuesday of that week — absent under the test schedule. +#: The Tuesday of that week — absent under the test home pattern. TUESDAY = "2026-01-06" +#: The panel service token the harness app is configured with. +HARNESS_PANEL_TOKEN = "admin-test-panel-token" + +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + def _admin_headers() -> dict[str, str]: """The Authorization header of a valid nestquest-admins JWT.""" return {"Authorization": f"Bearer {make_token()}"} -def _schedule_body( - cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None +def _patterns_url(child_id: int) -> str: + """The child-scoped pattern list/create route.""" + return f"/api/v1/admin/children/{child_id}/presence-patterns" + + +def _pattern_url(pattern_id: int | str) -> str: + """The id-scoped pattern edit/delete route.""" + return f"/api/v1/admin/presence-patterns/{pattern_id}" + + +def _pattern_body( + cycle_length_weeks: object = 1, + pattern: dict[str, object] | None = None, + *, + name: object = "Mum's house", + kind: object = "home", + anchor_date: object = ANCHOR_MONDAY, ) -> dict[str, object]: - """A valid schedule body anchored to the test Monday.""" + """A valid pattern body anchored to the test Monday (Mon/Wed/Fri home).""" return { + "name": name, + "kind": kind, "cycle_length_weeks": cycle_length_weeks, - "anchor_date": ANCHOR_MONDAY, + "anchor_date": anchor_date, "pattern": {"0": [0, 2, 4]} if pattern is None else pattern, } @@ -103,6 +140,18 @@ def _override_body( } +def _detail_names(response: object, field: str) -> bool: + """True when a 422's detail names ``field``. + + A core ValueError arrives as a string detail naming the field; a + Pydantic shape failure as a list of errors whose ``loc`` holds it. + """ + detail = response.json()["detail"] + if isinstance(detail, str): + return field in detail + return any(field in [str(part) for part in error["loc"]] for error in detail) + + async def _create_child(client: object, name: str = "Ada") -> int: """Create one child through the admin children route; return the id.""" created = await client.post( @@ -114,26 +163,44 @@ async def _create_child(client: object, name: str = "Ada") -> int: return created.json()["id"] +async def _create_pattern( + client: object, child_id: int, body: dict[str, object] | None = None +) -> dict[str, object]: + """Create one pattern through the admin route; return the payload.""" + created = await client.post( + _patterns_url(child_id), + json=_pattern_body() if body is None else body, + headers=_admin_headers(), + ) + assert created.status_code == 201, created.text + return created.json() + + def _engine( database: object, child_id: int, - schedule_record: object | None, + pattern_records: list[object], override_records: list[object], ) -> object: """Build the pure PresenceEngine from stored rows (the snapshot shape). Mirrors what the core snapshot builder assembles: the child's - decoded schedule (absent → present every day) plus its overrides, + decoded patterns (none → present every day) plus its overrides, so :meth:`PresenceEngine.is_present` answers from the SAME rows the routes produced. """ - schedules: dict[int, object] = {} - if schedule_record is not None: - schedules[child_id] = _presence.PresenceSchedule.decode( - schedule_record.child_id, - schedule_record.anchor_date, - schedule_record.pattern, - ) + patterns = { + child_id: [ + _presence.PresencePattern.decode( + record.child_id, + record.name, + record.kind, + record.anchor_date, + record.pattern, + ) + for record in pattern_records + ] + } overrides = { child_id: [ _presence.PresenceOverride( @@ -146,21 +213,21 @@ def _engine( for record in override_records ] } - return _presence.PresenceEngine(schedules, overrides) + return _presence.PresenceEngine(patterns, overrides) async def _child_state( database: object, child_id: int -) -> tuple[object | None, list[object], object]: - """The child's stored schedule record, overrides and PresenceEngine.""" - schedule_record = await _dao_presence.PresenceSchedulesDao( +) -> tuple[list[object], list[object], object]: + """The child's stored pattern records, overrides and PresenceEngine.""" + pattern_records = await _dao_presence.PresencePatternsDao( database - ).get_by_child(child_id) + ).list_by_child(child_id) override_records = await _dao_presence.PresenceOverridesDao( database ).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01") - engine = _engine(database, child_id, schedule_record, override_records) - return schedule_record, override_records, engine + engine = _engine(database, child_id, pattern_records, override_records) + return pattern_records, override_records, engine @pytest.fixture @@ -192,15 +259,13 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials( non_admin = make_token(groups=("some-other-group",)) non_admin_headers = {"Authorization": f"Bearer {non_admin}"} - forbidden_schedule = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body(), + forbidden_pattern = await client.post( + _patterns_url(child_id), + json=_pattern_body(), headers=non_admin_headers, ) - assert forbidden_schedule.status_code == 403 - assert forbidden_schedule.json()["detail"] == ( - "Admin access requires membership in the nestquest-admins group" - ) + assert forbidden_pattern.status_code == 403 + assert forbidden_pattern.json()["detail"] == FORBIDDEN_DETAIL forbidden_override = await client.post( "/api/v1/admin/presence-overrides", @@ -214,11 +279,10 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials( ) assert forbidden_delete.status_code == 403 - unauthenticated_schedule = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body(), + unauthenticated_pattern = await client.post( + _patterns_url(child_id), json=_pattern_body() ) - assert unauthenticated_schedule.status_code == 401 + assert unauthenticated_pattern.status_code == 401 unauthenticated_override = await client.post( "/api/v1/admin/presence-overrides", json=_override_body(child_id) @@ -230,113 +294,225 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials( ) assert unauthenticated_delete.status_code == 401 - # No route ran its handler: the child still has no schedule and no - # overrides (and is present every day, the no-schedule default). - _schedule, _overrides, engine = await _child_state( + # No route ran its handler: the child still has no pattern and no + # overrides (and is present every day, the no-pattern default). + patterns, overrides, engine = await _child_state( admin_presence.database, child_id ) - assert _schedule is None - assert _overrides == [] + assert patterns == [] + assert overrides == [] assert engine.is_present(child_id, ANCHOR_MONDAY) is True -# --- schedule upsert -------------------------------------------------------- +def _non_admin_headers() -> dict[str, str]: + return { + "Authorization": ( + f"Bearer {make_token(groups=('some-other-group',))}" + ) + } -async def test_schedule_upserts_and_replaces_one_row_per_child( +_CREDENTIALS = { + "absent": (lambda: {}, 401, None), + "panel-token": ( + lambda: {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}, + 403, + PANEL_TOKEN_REJECTION_DETAIL, + ), + "non-admin-jwt": (_non_admin_headers, 403, FORBIDDEN_DETAIL), +} + + +@pytest.mark.parametrize("credential", sorted(_CREDENTIALS)) +@pytest.mark.parametrize("route", ["list", "create", "edit", "delete"]) +async def test_pattern_routes_enforce_admin_auth( + admin_presence: SimpleNamespace, route: str, credential: str +) -> None: + """Every pattern route rejects absent, panel and non-admin credentials. + + Each refused request leaves the stored pattern exactly as it was: a + refused create adds no row, a refused edit changes nothing, a + refused delete removes nothing. + """ + client = admin_presence.client + child_id = await _create_child(client) + existing = await _create_pattern(client, child_id) + make_headers, status, detail = _CREDENTIALS[credential] + + if route == "list": + response = await client.get( + _patterns_url(child_id), headers=make_headers() + ) + elif route == "create": + response = await client.post( + _patterns_url(child_id), + json=_pattern_body(name="Dad's house"), + headers=make_headers(), + ) + elif route == "edit": + response = await client.patch( + _pattern_url(existing["id"]), + json={"name": "Renamed"}, + headers=make_headers(), + ) + else: + response = await client.delete( + _pattern_url(existing["id"]), headers=make_headers() + ) + + assert response.status_code == status + if detail is not None: + assert response.json()["detail"] == detail + listed = await client.get(_patterns_url(child_id), headers=_admin_headers()) + assert listed.json() == {"patterns": [existing]} + + +# --- the removed one-per-child schedule route ---------------------------------- + + +async def test_old_presence_schedule_route_is_gone( admin_presence: SimpleNamespace, ) -> None: - """PUT /presence-schedule persists, and setting again REPLACES it.""" + """PUT/GET /presence-schedule no longer exist; nothing is written.""" + client = admin_presence.client + child_id = await _create_child(client) + url = f"/api/v1/admin/children/{child_id}/presence-schedule" + + put = await client.put( + url, + json={ + "cycle_length_weeks": 1, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [0]}, + }, + headers=_admin_headers(), + ) + assert put.status_code in (404, 405) + get = await client.get(url, headers=_admin_headers()) + assert get.status_code in (404, 405) + patterns, _overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert patterns == [] + + +# --- pattern create --------------------------------------------------------- + + +async def test_pattern_create_persists_and_adds_a_row_per_create( + admin_presence: SimpleNamespace, +) -> None: + """POST /presence-patterns persists; a second POST ADDS another row.""" client = admin_presence.client child_id = await _create_child(client) - first = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body(), + first = await client.post( + _patterns_url(child_id), + json=_pattern_body(), headers=_admin_headers(), ) - assert first.status_code == 200 - assert first.json() == { + assert first.status_code == 201 + first_payload = first.json() + assert first_payload == { + "id": first_payload["id"], "child_id": child_id, + "name": "Mum's house", + "kind": "home", "cycle_length_weeks": 1, "anchor_date": ANCHOR_MONDAY, "pattern": {"0": [0, 2, 4]}, } - # Database proof: exactly ONE schedule row (the schema's - # UNIQUE(child_id) makes a second row unrepresentable), stored as - # encoded CSV. - schedule, _overrides, _engine = await _child_state( + # Database proof: ONE pattern row, stored as encoded CSV. + patterns, _overrides, _engine = await _child_state( admin_presence.database, child_id ) - assert schedule is not None - row_id = schedule.id - assert schedule.cycle_length_weeks == 1 - assert schedule.anchor_date == ANCHOR_MONDAY - assert schedule.pattern == "0,2,4" + assert [record.id for record in patterns] == [first_payload["id"]] + assert patterns[0].name == "Mum's house" + assert patterns[0].kind == "home" + assert patterns[0].cycle_length_weeks == 1 + assert patterns[0].anchor_date == ANCHOR_MONDAY + assert patterns[0].pattern == "0,2,4" - # Setting again UPSERTS: the SAME row is updated in place — new - # values, unchanged id — never a duplicate or a stale first set. - second = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body( - cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]} + # Creating again ADDS a second pattern beside the first — the first + # row is untouched (schema 9 has no one-per-child constraint). + second = await client.post( + _patterns_url(child_id), + json=_pattern_body( + cycle_length_weeks=2, + pattern={"0": [], "1": [3, 1]}, + name=" Holiday rota ", + kind="away", ), headers=_admin_headers(), ) - assert second.status_code == 200 - assert second.json() == { + assert second.status_code == 201 + second_payload = second.json() + assert second_payload == { + "id": second_payload["id"], "child_id": child_id, + "name": "Holiday rota", + "kind": "away", "cycle_length_weeks": 2, "anchor_date": ANCHOR_MONDAY, "pattern": {"0": [], "1": [1, 3]}, } - schedule, _overrides, _engine = await _child_state( + assert second_payload["id"] != first_payload["id"] + patterns, _overrides, _engine = await _child_state( admin_presence.database, child_id ) - assert schedule is not None - assert schedule.id == row_id - assert schedule.cycle_length_weeks == 2 - assert schedule.pattern == "|1,3" + assert [record.id for record in patterns] == [ + first_payload["id"], + second_payload["id"], + ] + assert patterns[0].pattern == "0,2,4" + assert patterns[1].pattern == "|1,3" -async def test_schedule_changes_the_childs_resolved_presence( +async def test_pattern_changes_the_childs_resolved_presence( admin_presence: SimpleNamespace, ) -> None: - """The resolved presence (PresenceEngine) follows the stored schedule.""" + """The resolved presence (PresenceEngine) follows the stored patterns.""" client = admin_presence.client child_id = await _create_child(client) - # Before any schedule the child is present every day. - _schedule, _overrides, engine = await _child_state( + # Before any pattern the child is present every day. + _patterns, _overrides, engine = await _child_state( admin_presence.database, child_id ) assert engine.is_present(child_id, ANCHOR_MONDAY) is True assert engine.is_present(child_id, TUESDAY) is True - # Mon/Wed/Fri only, week 0 of a one-week cycle. - response = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body(), - headers=_admin_headers(), - ) - assert response.status_code == 200 - _schedule, _overrides, engine = await _child_state( + # Home Mon/Wed/Fri only, week 0 of a one-week cycle. + await _create_pattern(client, child_id) + _patterns, _overrides, engine = await _child_state( admin_presence.database, child_id ) assert engine.is_present(child_id, ANCHOR_MONDAY) is True assert engine.is_present(child_id, TUESDAY) is False + # An away pattern covering Monday beats the home pattern there. + await _create_pattern( + client, + child_id, + _pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"), + ) + _patterns, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert engine.is_present(child_id, ANCHOR_MONDAY) is False + assert engine.is_present(child_id, "2026-01-07") is True # Wednesday + assert engine.is_present(child_id, TUESDAY) is False -async def test_schedule_upsert_regenerates_future_instances( + +async def test_pattern_writes_regenerate_future_instances( admin_presence: SimpleNamespace, ) -> None: - """Setting an all-absent schedule deletes the child's future instances. + """An all-absent home pattern deletes the child's future instances. The regeneration side effect lives in the core layer the route calls: the child's open instances at or after today are deleted across ALL its definitions and NOT rebuilt while the child is - absent every day — the same proof the HA service test makes, - exercised here through the API route instead. + absent every day; deleting the pattern rebuilds them. """ client = admin_presence.client database = admin_presence.database @@ -360,16 +536,257 @@ async def test_schedule_upsert_regenerates_future_instances( instances = _dao_instances.QuestInstancesDao(database) assert await instances.list_by_date_range(child_id, start, end) - # The route's write must regenerate: absent every day removes every - # open future instance. - response = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body(pattern={"0": []}), + # The route's write must regenerate: a home pattern covering no day + # makes the child absent every day and removes every open instance. + pattern = await _create_pattern( + client, child_id, _pattern_body(pattern={"0": []}) + ) + assert await instances.list_by_date_range(child_id, start, end) == [] + + # Deleting it regenerates too: present every day again, rebuilt. + deleted = await client.delete( + _pattern_url(pattern["id"]), headers=_admin_headers() + ) + assert deleted.status_code == 200 + assert await instances.list_by_date_range(child_id, start, end) + + +async def test_pattern_edit_regenerates_future_instances( + admin_presence: SimpleNamespace, +) -> None: + """PATCH regenerates: flipping an empty home pattern to away rebuilds.""" + client = admin_presence.client + database = admin_presence.database + child_id = await _create_child(client) + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child_id], + "windows": ["morning"], + }, headers=_admin_headers(), ) - assert response.status_code == 200 + assert created.status_code == 201 + today = datetime.date.today() + start = today.isoformat() + end = (today + datetime.timedelta(days=14)).isoformat() + pattern = await _create_pattern( + client, child_id, _pattern_body(pattern={"0": []}) + ) + await _materialize.materialize(database, start, end, today=today) + instances = _dao_instances.QuestInstancesDao(database) assert await instances.list_by_date_range(child_id, start, end) == [] + # An away pattern covering no day leaves no home pattern: present + # every day, so the edit's regeneration builds the instances. + edited = await client.patch( + _pattern_url(pattern["id"]), + json={"kind": "away"}, + headers=_admin_headers(), + ) + assert edited.status_code == 200 + assert await instances.list_by_date_range(child_id, start, end) + + +# --- pattern edit (PATCH) ------------------------------------------------------ + + +async def test_pattern_patch_changes_only_supplied_fields( + admin_presence: SimpleNamespace, +) -> None: + """A partial PATCH changes the supplied fields and keeps the rest.""" + client = admin_presence.client + child_id = await _create_child(client) + original = await _create_pattern(client, child_id) + sibling = await _create_pattern( + client, child_id, _pattern_body(name="Sibling", kind="away") + ) + + renamed = await client.patch( + _pattern_url(original["id"]), + json={"name": "Weekday home"}, + headers=_admin_headers(), + ) + assert renamed.status_code == 200 + assert renamed.json() == dict(original, name="Weekday home") + + reshaped = await client.patch( + _pattern_url(original["id"]), + json={"cycle_length_weeks": 2, "pattern": {"0": [1], "1": [6, 5]}}, + headers=_admin_headers(), + ) + assert reshaped.status_code == 200 + assert reshaped.json() == dict( + original, + name="Weekday home", + cycle_length_weeks=2, + pattern={"0": [1], "1": [5, 6]}, + ) + + moved = await client.patch( + _pattern_url(original["id"]), + json={"anchor_date": "2026-01-12", "kind": "away"}, + headers=_admin_headers(), + ) + assert moved.status_code == 200 + expected = dict( + reshaped.json(), anchor_date="2026-01-12", kind="away" + ) + assert moved.json() == expected + + # Database proof: the edited row, same id, and the sibling untouched. + patterns, _overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert [record.id for record in patterns] == [ + original["id"], + sibling["id"], + ] + assert patterns[0].name == "Weekday home" + assert patterns[0].kind == "away" + assert patterns[0].cycle_length_weeks == 2 + assert patterns[0].anchor_date == "2026-01-12" + assert patterns[0].pattern == "1|5,6" + listed = await client.get(_patterns_url(child_id), headers=_admin_headers()) + assert listed.json() == {"patterns": [expected, sibling]} + + +async def test_pattern_patch_unknown_id_is_404( + admin_presence: SimpleNamespace, +) -> None: + """Editing a pattern id that does not exist is 404.""" + response = await admin_presence.client.patch( + _pattern_url(999), json={"name": "Nope"}, headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Presence pattern not found" + + malformed = await admin_presence.client.patch( + _pattern_url("not-an-id"), + json={"name": "Nope"}, + headers=_admin_headers(), + ) + assert malformed.status_code == 422 + + +@pytest.mark.parametrize( + ("edit", "field"), + [ + ({"name": ""}, "name"), + ({"name": " "}, "name"), + ({"name": None}, "name"), + ({"kind": "sometimes"}, "kind"), + ({"kind": None}, "kind"), + ({"cycle_length_weeks": 0}, "cycle_length_weeks"), + ({"cycle_length_weeks": 5}, "cycle_length_weeks"), + ({"cycle_length_weeks": 2.5}, "cycle_length_weeks"), + ({"cycle_length_weeks": "2"}, "cycle_length_weeks"), + ({"cycle_length_weeks": None}, "cycle_length_weeks"), + # A longer cycle without a matching pattern leaves week 1 uncovered. + ({"cycle_length_weeks": 2}, "pattern"), + ({"anchor_date": "2026-13-40"}, "anchor_date"), + ({"anchor_date": None}, "anchor_date"), + ({"pattern": {}}, "pattern"), + ({"pattern": None}, "pattern"), + ({"pattern": {"0": [7]}}, "pattern"), # weekday out of 0..6 + # Strict JSON integer weekdays: no bool, string or float coercion. + ({"pattern": {"0": [True]}}, "pattern"), + ({"pattern": {"0": [False]}}, "pattern"), + ({"pattern": {"0": ["1"]}}, "pattern"), + ({"pattern": {"0": [1.0]}}, "pattern"), + ], +) +async def test_rejected_pattern_patch_is_422_and_changes_nothing( + admin_presence: SimpleNamespace, edit: dict[str, object], field: str +) -> None: + """A rejected edit (incl. explicit null) is 422 naming the field.""" + client = admin_presence.client + child_id = await _create_child(client) + original = await _create_pattern(client, child_id) + + response = await client.patch( + _pattern_url(original["id"]), json=edit, headers=_admin_headers() + ) + assert response.status_code == 422, response.text + assert _detail_names(response, field), response.json() + listed = await client.get(_patterns_url(child_id), headers=_admin_headers()) + assert listed.json() == {"patterns": [original]} + + +# --- pattern delete ------------------------------------------------------------ + + +async def test_pattern_delete_removes_one_pattern_and_presence_resolves( + admin_presence: SimpleNamespace, +) -> None: + """DELETE removes the one pattern; its siblings and presence follow.""" + client = admin_presence.client + child_id = await _create_child(client) + home = await _create_pattern(client, child_id) + away = await _create_pattern( + client, + child_id, + _pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"), + ) + _patterns, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert engine.is_present(child_id, ANCHOR_MONDAY) is False + + deleted = await client.delete( + _pattern_url(away["id"]), headers=_admin_headers() + ) + assert deleted.status_code == 200 + assert deleted.json() == {"status": "ok"} + assert ( + await _dao_presence.PresencePatternsDao(admin_presence.database).get( + away["id"] + ) + is None + ) + patterns, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert [record.id for record in patterns] == [home["id"]] + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + assert engine.is_present(child_id, TUESDAY) is False + + # Deleting the last pattern: present every day again. + deleted = await client.delete( + _pattern_url(home["id"]), headers=_admin_headers() + ) + assert deleted.status_code == 200 + patterns, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert patterns == [] + assert engine.is_present(child_id, TUESDAY) is True + + # A second delete of the same id is 404: it no longer exists. + again = await client.delete( + _pattern_url(home["id"]), headers=_admin_headers() + ) + assert again.status_code == 404 + assert again.json()["detail"] == "Presence pattern not found" + + +async def test_delete_unknown_pattern_is_404( + admin_presence: SimpleNamespace, +) -> None: + """Deleting a pattern id that does not exist is 404.""" + response = await admin_presence.client.delete( + _pattern_url(999), headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Presence pattern not found" + + malformed = await admin_presence.client.delete( + _pattern_url("not-an-id"), headers=_admin_headers() + ) + assert malformed.status_code == 422 + # --- override create + delete ----------------------------------------------- @@ -381,12 +798,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves( client = admin_presence.client database = admin_presence.database child_id = await _create_child(client) - schedule_response = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", - json=_schedule_body(), - headers=_admin_headers(), - ) - assert schedule_response.status_code == 200 + await _create_pattern(client, child_id) # Create: the override comes back with its id and the posted shape. created = await client.post( @@ -412,7 +824,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves( # Database proof: the row exists — and the RESOLVED presence for the # affected date flips: the override beats the Tuesday-absent pattern. - _schedule, overrides, engine = await _child_state(database, child_id) + _patterns, overrides, engine = await _child_state(database, child_id) assert [record.id for record in overrides] == [payload["id"]] assert engine.is_present(child_id, TUESDAY) is True # A date the override does not cover keeps the pattern's answer. @@ -430,7 +842,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves( await _dao_presence.PresenceOverridesDao(database).get(payload["id"]) is None ) - _schedule, overrides, engine = await _child_state(database, child_id) + _patterns, overrides, engine = await _child_state(database, child_id) assert overrides == [] assert engine.is_present(child_id, TUESDAY) is False @@ -438,19 +850,19 @@ async def test_override_is_created_then_deleted_and_presence_resolves( # --- error mapping: 404 vs 422 ---------------------------------------------- -async def test_schedule_and_override_for_unknown_child_are_404( +async def test_pattern_and_override_for_unknown_child_are_404( admin_presence: SimpleNamespace, ) -> None: """A presence write naming a child that does not exist is 404.""" client = admin_presence.client - schedule = await client.put( - "/api/v1/admin/children/999/presence-schedule", - json=_schedule_body(), + pattern = await client.post( + _patterns_url(999), + json=_pattern_body(), headers=_admin_headers(), ) - assert schedule.status_code == 404 - assert schedule.json()["detail"] == "Child not found" + assert pattern.status_code == 404 + assert pattern.json()["detail"] == "Child not found" override = await client.post( "/api/v1/admin/presence-overrides", @@ -462,42 +874,70 @@ async def test_schedule_and_override_for_unknown_child_are_404( @pytest.mark.parametrize( - "body", + ("body", "field"), [ - _schedule_body(cycle_length_weeks=0), # below the 1..4 range - _schedule_body(cycle_length_weeks=5), # above the documented cap + (_pattern_body(cycle_length_weeks=0), "cycle_length_weeks"), + (_pattern_body(cycle_length_weeks=5), "cycle_length_weeks"), + # Strict JSON integers only: no float or string coercion. + (_pattern_body(cycle_length_weeks=2.5), "cycle_length_weeks"), + (_pattern_body(cycle_length_weeks="2"), "cycle_length_weeks"), + (_pattern_body(cycle_length_weeks=1.0), "cycle_length_weeks"), # week 1 of the cycle missing: - _schedule_body(cycle_length_weeks=2, pattern={"0": [0]}), - { - "cycle_length_weeks": 1, - "anchor_date": "2026-13-40", - "pattern": {"0": [0]}, - }, - _schedule_body(pattern={"0": [7]}), # weekday out of 0..6 - _schedule_body(pattern={"nope": [0]}), # week index not an integer - _schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list + (_pattern_body(cycle_length_weeks=2, pattern={"0": [0]}), "pattern"), + (_pattern_body(pattern={}), "pattern"), + (_pattern_body(anchor_date="2026-13-40"), "anchor_date"), + (_pattern_body(anchor_date="05/01/2026"), "anchor_date"), + (_pattern_body(pattern={"0": [7]}), "pattern"), # weekday out of 0..6 + # Strict JSON integer weekdays: no bool, string or float coercion. + (_pattern_body(pattern={"0": [True]}), "pattern"), + (_pattern_body(pattern={"0": [False]}), "pattern"), + (_pattern_body(pattern={"0": ["1"]}), "pattern"), + (_pattern_body(pattern={"0": [1.0]}), "pattern"), + (_pattern_body(pattern={"nope": [0]}), "pattern"), # non-int week + (_pattern_body(pattern={"0": "0,2,4"}), "pattern"), # not a list + (_pattern_body(kind="sometimes"), "kind"), + (_pattern_body(kind="HOME"), "kind"), + (_pattern_body(name=""), "name"), + (_pattern_body(name=" "), "name"), ], ) -async def test_rejected_schedule_is_422( - admin_presence: SimpleNamespace, body: dict[str, object] +async def test_rejected_pattern_is_422( + admin_presence: SimpleNamespace, body: dict[str, object], field: str ) -> None: - """A rejected schedule is 422 and stores nothing.""" + """A rejected pattern is 422 naming the field, and stores nothing.""" client = admin_presence.client child_id = await _create_child(client) - response = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", + response = await client.post( + _patterns_url(child_id), json=body, headers=_admin_headers(), ) - assert response.status_code == 422 + assert response.status_code == 422, response.text + assert _detail_names(response, field), response.json() assert ( - await _dao_presence.PresenceSchedulesDao( + await _dao_presence.PresencePatternsDao( admin_presence.database - ).get_by_child(child_id) - is None + ).list_by_child(child_id) + == [] ) +@pytest.mark.parametrize("missing", ["name", "kind"]) +async def test_pattern_missing_required_field_is_422( + admin_presence: SimpleNamespace, missing: str +) -> None: + """The new required fields cannot be omitted (an old schedule body).""" + client = admin_presence.client + child_id = await _create_child(client) + body = _pattern_body() + del body[missing] + response = await client.post( + _patterns_url(child_id), json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + assert _detail_names(response, missing) + + @pytest.mark.parametrize( "body", [ @@ -548,7 +988,7 @@ async def test_overlapping_override_is_422_and_first_survives( headers=_admin_headers(), ) assert response.status_code == 422 - _schedule, overrides, _engine = await _child_state( + _patterns, overrides, _engine = await _child_state( admin_presence.database, child_id ) assert [record.id for record in overrides] == [created.json()["id"]] diff --git a/tests/test_api_admin_presence_consequence.py b/tests/test_api_admin_presence_consequence.py index 4bdaac4..efa28e5 100644 --- a/tests/test_api_admin_presence_consequence.py +++ b/tests/test_api_admin_presence_consequence.py @@ -124,18 +124,20 @@ async def _create_daily_definition( return created.json()["id"] -async def _set_custody_schedule(client: object, child_id: int) -> None: - """Anchor the two-week custody cycle on today (week 0 starts today).""" - response = await client.put( - f"/api/v1/admin/children/{child_id}/presence-schedule", +async def _set_custody_pattern(client: object, child_id: int) -> None: + """Add the two-week custody home pattern anchored on today (week 0).""" + response = await client.post( + f"/api/v1/admin/children/{child_id}/presence-patterns", json={ + "name": "Custody", + "kind": "home", "cycle_length_weeks": 2, "anchor_date": _day(0), "pattern": CUSTODY_PATTERN, }, headers=_admin_headers(), ) - assert response.status_code == 200 + assert response.status_code == 201 async def _materialize_horizon(database: object) -> None: @@ -210,7 +212,7 @@ async def custody(temp_db_path: str) -> SimpleNamespace: client, child_id, "Feed the fish", ["afternoon"], skip_on_away=False, ) - await _set_custody_schedule(client, child_id) + await _set_custody_pattern(client, child_id) await _materialize_horizon(database) yield SimpleNamespace( client=client, @@ -290,7 +292,7 @@ async def test_skip_on_away_false_definition_contributes_nothing( client, child_id, "Feed the fish", ["morning"], skip_on_away=False, ) - await _set_custody_schedule(client, child_id) + await _set_custody_pattern(client, child_id) await _materialize_horizon(database) assert await _consequence(client, _body(child_id, 0, 14)) == 0 diff --git a/tests/test_api_admin_presence_reads.py b/tests/test_api_admin_presence_reads.py index 9ab1e88..02ef24b 100644 --- a/tests/test_api_admin_presence_reads.py +++ b/tests/test_api_admin_presence_reads.py @@ -1,14 +1,14 @@ """Admin-plane presence READ route tests (task 07fde892). -``GET /api/v1/admin/children/{child_id}/presence-schedule`` and +``GET /api/v1/admin/children/{child_id}/presence-patterns`` and ``GET /api/v1/admin/presence-overrides`` against the ASGI app with httpx, through the shared stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) — no network access. -- The schedule read answers ``{"schedule": {...}}`` in the PUT - response's shape for a seeded schedule, ``{"schedule": null}`` for a - child with none (present every day — NOT a 404), and 404 for an - unknown child. +- The patterns read answers ``{"patterns": [...]}`` in the POST + response's shape for seeded patterns, ordered by id (a child may have + several), ``{"patterns": []}`` for a child with none (present every + day — NOT a 404), and 404 for an unknown child. - The overrides read answers ``{"overrides": [...]}`` in the POST response's shape, ordered by start date, child id, id; ``child_id`` and the ``start``/``end`` range each narrow the list; a malformed or @@ -44,8 +44,8 @@ FORBIDDEN_DETAIL = ( ) -def _schedule_url(child_id: int) -> str: - return f"/api/v1/admin/children/{child_id}/presence-schedule" +def _patterns_url(child_id: int) -> str: + return f"/api/v1/admin/children/{child_id}/presence-patterns" def _admin_headers() -> dict[str, str]: @@ -99,20 +99,23 @@ async def reads(temp_db_path: str) -> SimpleNamespace: @pytest.fixture async def seeded(reads: SimpleNamespace) -> SimpleNamespace: - """Two children with interleaved overrides (Ada has a schedule).""" + """Two children with interleaved overrides (Ada has a home pattern).""" client = reads.client ada = await _create_child(client, "Ada") ben = await _create_child(client, "Ben") - put = await client.put( - _schedule_url(ada), + created = await client.post( + _patterns_url(ada), json={ + "name": "Custody", + "kind": "home", "cycle_length_weeks": 2, "anchor_date": "2026-01-05", "pattern": {"0": [4, 0, 2], "1": []}, }, headers=_admin_headers(), ) - assert put.status_code == 200 + assert created.status_code == 201, created.text + ada_custody = created.json() ada_march = await _create_override( client, ada, "2026-03-01", "2026-03-07", note="grandparents" ) @@ -129,52 +132,114 @@ async def seeded(reads: SimpleNamespace) -> SimpleNamespace: ben_january=ben_january, ben_march=ben_march, ada_may=ada_may, + ada_custody=ada_custody, ) -# --- presence schedule read ------------------------------------------------ +# --- presence patterns read ------------------------------------------------ -async def test_schedule_read_returns_the_stored_schedule( +async def test_patterns_read_returns_the_stored_patterns( seeded: SimpleNamespace, ) -> None: - """A seeded schedule reads back in the PUT response's shape.""" + """A seeded pattern reads back in the POST response's shape.""" response = await seeded.client.get( - _schedule_url(seeded.ada), headers=_admin_headers() + _patterns_url(seeded.ada), headers=_admin_headers() ) assert response.status_code == 200 assert response.json() == { - "schedule": { - "child_id": seeded.ada, - "cycle_length_weeks": 2, - "anchor_date": "2026-01-05", - "pattern": {"0": [0, 2, 4], "1": []}, - } + "patterns": [ + { + "id": seeded.ada_custody["id"], + "child_id": seeded.ada, + "name": "Custody", + "kind": "home", + "cycle_length_weeks": 2, + "anchor_date": "2026-01-05", + "pattern": {"0": [0, 2, 4], "1": []}, + } + ] } + assert response.json()["patterns"] == [seeded.ada_custody] -async def test_schedule_read_without_schedule_is_null_not_404( +async def test_patterns_read_lists_several_patterns_in_id_order( seeded: SimpleNamespace, ) -> None: - """A child with no schedule (present every day) is schedule: null.""" - response = await seeded.client.get( - _schedule_url(seeded.ben), headers=_admin_headers() + """A child's patterns (zero or more) are listed ordered by id.""" + client = seeded.client + away = await client.post( + _patterns_url(seeded.ada), + json={ + "name": "Camp", + "kind": "away", + "cycle_length_weeks": 1, + "anchor_date": "2026-01-05", + "pattern": {"0": [6, 5]}, + }, + headers=_admin_headers(), + ) + assert away.status_code == 201, away.text + third = await client.post( + _patterns_url(seeded.ada), + json={ + "name": "Weekday lessons", + "kind": "home", + "cycle_length_weeks": 1, + "anchor_date": "2026-01-05", + "pattern": {"0": [1]}, + }, + headers=_admin_headers(), + ) + assert third.status_code == 201, third.text + + response = await client.get( + _patterns_url(seeded.ada), headers=_admin_headers() ) assert response.status_code == 200 - assert response.json() == {"schedule": None} + patterns = response.json()["patterns"] + assert patterns == [seeded.ada_custody, away.json(), third.json()] + ids = [pattern["id"] for pattern in patterns] + assert ids == sorted(ids) + assert patterns[1]["pattern"] == {"0": [5, 6]} + # Ben's list is untouched by Ada's patterns. + ben = await client.get(_patterns_url(seeded.ben), headers=_admin_headers()) + assert ben.json() == {"patterns": []} -async def test_schedule_read_unknown_child_is_404( +async def test_patterns_read_without_patterns_is_empty_not_404( + seeded: SimpleNamespace, +) -> None: + """A child with no pattern (present every day) is an empty list.""" + response = await seeded.client.get( + _patterns_url(seeded.ben), headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == {"patterns": []} + + +async def test_patterns_read_unknown_child_is_404( reads: SimpleNamespace, ) -> None: """An unknown child is 404 with the children routes' detail.""" response = await reads.client.get( - _schedule_url(999), headers=_admin_headers() + _patterns_url(999), headers=_admin_headers() ) assert response.status_code == 404 assert response.json() == {"detail": "Child not found"} +async def test_old_presence_schedule_read_route_is_gone( + seeded: SimpleNamespace, +) -> None: + """The removed one-per-child schedule read no longer exists.""" + response = await seeded.client.get( + f"/api/v1/admin/children/{seeded.ada}/presence-schedule", + headers=_admin_headers(), + ) + assert response.status_code in (404, 405) + + # --- presence overrides read ----------------------------------------------- @@ -302,12 +367,12 @@ async def test_overrides_read_unknown_child_is_404( # --- authentication: the router's ONE require_admin -------------------------- -@pytest.fixture(params=["schedule", "overrides"]) +@pytest.fixture(params=["patterns", "overrides"]) async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str: - """Each read route's URL (the schedule route for a real child).""" + """Each read route's URL (the patterns route for a real child).""" if request.param == "overrides": return OVERRIDES_URL - return _schedule_url(await _create_child(reads.client, "Ada")) + return _patterns_url(await _create_child(reads.client, "Ada")) async def test_read_without_credential_is_401( diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index 5d8489b..a37f894 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -141,7 +141,7 @@ async def _seed_household( [bo.id], [("morning", "17:00")], ) - # Today's instances first, while Ada has no presence schedule + # Today's instances first, while Ada has no presence pattern # (she is present every day, so her instance IS generated). await _materialize.materialize( database, @@ -183,12 +183,14 @@ async def _seed_household( if record.due_time == "09:00" ) - # Ada's custody schedule, added AFTER materialization (the + # Ada's custody pattern (one home pattern), added AFTER materialization (the # same order test_core_snapshot.py seeds): 2-week cycle # anchored yesterday, empty week 0 covers today => absent # today; an override returns her on today+2. - await _dao_presence.PresenceSchedulesDao(database).upsert_by_child( + await _dao_presence.PresencePatternsDao(database).create( ada.id, + name="Custody", + kind="home", cycle_length_weeks=2, anchor_date=yesterday_iso, pattern="|0,1,2,3,4,5,6", diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index 541c4f6..700f8a5 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -36,7 +36,7 @@ from custom_components.nestquest.core.snapshot import ( from custom_components.nestquest.core.dao_instances import QuestInstancesDao from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, - PresenceSchedulesDao, + PresencePatternsDao, ) from custom_components.nestquest.core.db import NestQuestDatabase from custom_components.nestquest.core.materialize import materialize @@ -116,14 +116,16 @@ async def test_instance_payload_shape_and_missed_omission() -> None: async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None: """The builder produces the documented shape as literals, pinned in time. - Ada is AWAY today (a 2-week schedule whose absent week 0 covers today, + Ada is AWAY today (a 2-week home pattern whose empty week 0 covers today, plus a presence override marking her present on today+2) so ``present`` is False and ``next_present`` is the override date. Her open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``. Bo's quest due at 17:00 is completed at 12:00 (on time) so its payload ``state`` is ``completed`` and ``on_time`` is True. The first - scheduled active child is Ada, whose 2-week cycle anchored one day - before today puts ``cycle_day`` at 2. + patterned active child is Ada, whose FIRST (lowest id) pattern is a + 2-week cycle anchored one day before today, putting ``cycle_day`` at + 2; her second pattern (a 3-week one anchored today, covering no day) + must not drive the cycle day. """ # A standalone core database (the integration owns none): the # builder and its seed run against it through the executor. @@ -172,12 +174,25 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non # covers today, so she is absent today; an override marks her present # on today+2, so next_present resolves to that date and cycle_day is # ((today - (today-1)) % 14) + 1 == 2. - await PresenceSchedulesDao(database).upsert_by_child( + patterns = PresencePatternsDao(database) + await patterns.create( ada.id, + name="Home schedule", + kind="home", cycle_length_weeks=2, anchor_date=(today - datetime.timedelta(days=1)).isoformat(), pattern="|0,1,2,3,4,5,6", ) + # A later pattern covering no day: it changes no presence answer and, + # not being the first pattern, must not set cycle_day (it would be 1). + await patterns.create( + ada.id, + name="Placeholder away", + kind="away", + cycle_length_weeks=3, + anchor_date=today_iso, + pattern="||", + ) returns_date = today + datetime.timedelta(days=2) await PresenceOverridesDao(database).create( ada.id, diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 59961f5..08b2e64 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -31,7 +31,7 @@ from custom_components.nestquest.core.dao_instances import ( ) from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, - PresenceSchedulesDao, + PresencePatternsDao, ) from custom_components.nestquest.core.dao_rules import ( ScheduleRulesDao, @@ -72,7 +72,7 @@ ALL_TABLES = ( "schedule_rules", "quest_definitions", "quest_definition_assignees", - "presence_schedules", + "presence_patterns", "presence_overrides", "quest_instances", "completion_events", @@ -88,7 +88,7 @@ class World: self.admins = AdminUsersDao(database) self.rules = ScheduleRulesDao(database) self.definitions = QuestDefinitionsDao(database) - self.schedules = PresenceSchedulesDao(database) + self.patterns = PresencePatternsDao(database) self.overrides = PresenceOverridesDao(database) self.instances = QuestInstancesDao(database) self.events = CompletionEventsDao(database) @@ -105,8 +105,8 @@ class World: _stamp(), assignee_child_ids=[self.child.id], ) - self.schedule = await self.schedules.upsert_by_child( - self.child.id, 2, D1, "0,2,4|1,3" + self.pattern = await self.patterns.create( + self.child.id, "Home schedule", "home", 2, D1, "0,2,4|1,3" ) self.override = await self.overrides.create( self.child.id, D1, D2, True, note="test" @@ -244,28 +244,39 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None: assert _with_world(tmp_path / "m-definitions.db")(_body) is True -def test_matrix_presence_schedules_crud_and_constraints(tmp_path) -> None: +def test_matrix_presence_patterns_crud_and_constraints(tmp_path) -> None: async def _body(w: World): # insert (seeded) + read - fetched = await w.schedules.get_by_child(w.child.id) + fetched = await w.patterns.get(w.pattern.id) + assert fetched == w.pattern assert fetched.pattern == "0,2,4|1,3" - # update path: upsert replaces in place (same row id) - replaced = await w.schedules.upsert_by_child( - w.child.id, 2, D2, "1,3|0,2,4" + # a second pattern for the same child is allowed (schema 9) + away = await w.patterns.create( + w.child.id, "Weekend away", "away", 1, D1, "5,6" + ) + assert [r.id for r in await w.patterns.list_by_child(w.child.id)] == [ + w.pattern.id, + away.id, + ] + # update path: rewrites in place (same row id) + replaced = await w.patterns.update( + w.pattern.id, "Home schedule", "home", 2, D2, "1,3|0,2,4" ) assert replaced.id == fetched.id - assert (await w.schedules.get_by_child(w.child.id)).pattern == ( - "1,3|0,2,4" - ) + assert (await w.patterns.get(w.pattern.id)).pattern == "1,3|0,2,4" # constraint: malformed pattern CHECK with pytest.raises(sqlite3.IntegrityError): - await w.schedules.upsert_by_child(w.child.id, 2, D1, "0,2") - # delete: row gone, child present every day - assert await w.schedules.delete(w.child.id) is True - assert await w.schedules.get_by_child(w.child.id) is None + await w.patterns.create(w.child.id, "Bad", "home", 2, D1, "0,2") + # constraint: kind CHECK + with pytest.raises(sqlite3.IntegrityError): + await w.patterns.create(w.child.id, "Bad", "maybe", 1, D1, "0") + # delete: rows gone, child present every day + assert await w.patterns.delete(w.pattern.id) is True + assert await w.patterns.delete(away.id) is True + assert await w.patterns.list_by_child(w.child.id) == [] return True - assert _with_world(tmp_path / "m-schedules.db")(_body) is True + assert _with_world(tmp_path / "m-patterns.db")(_body) is True def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None: diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 8d4cbf6..efec0ed 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -1,4 +1,4 @@ -"""Tests for dao_presence.py: presence schedules and overrides DAO.""" +"""Tests for dao_presence.py: presence patterns and overrides DAO.""" from __future__ import annotations import asyncio @@ -10,8 +10,8 @@ from custom_components.nestquest.core.dao_children import ChildrenDao from custom_components.nestquest.core.dao_presence import ( PresenceOverrideRecord, PresenceOverridesDao, - PresenceScheduleRecord, - PresenceSchedulesDao, + PresencePatternRecord, + PresencePatternsDao, ) from custom_components.nestquest.core.db import NestQuestDatabase from custom_components.nestquest.core.migrations import apply_migrations @@ -36,22 +36,22 @@ async def _prepare(path) -> tuple: database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) await database.open(path) await apply_migrations(database) - schedules = PresenceSchedulesDao(database) + patterns = PresencePatternsDao(database) overrides = PresenceOverridesDao(database) children = ChildrenDao(database) child = await children.create("Ada", NOW) - return database, schedules, overrides, children, child + return database, patterns, overrides, children, child def _with_db(tmp_path, name): def _run_test(body): async def _main(): - database, schedules, overrides, children, child = await _prepare( + database, patterns, overrides, children, child = await _prepare( tmp_path / name ) try: return await body( - database, schedules, overrides, children, child + database, patterns, overrides, children, child ) finally: await database.close() @@ -62,105 +62,152 @@ def _with_db(tmp_path, name): # --------------------------------------------------------------------------- -# presence_schedules: upsert by child +# presence_patterns: create / list by child # --------------------------------------------------------------------------- -def test_upsert_creates_then_updates_same_row(tmp_path) -> None: - """Second upsert for the same child UPDATES, not raises.""" - async def _body(database, schedules, overrides, children, child): - first = await schedules.upsert_by_child( - child.id, 2, "2026-09-07", "0,2,4|1,3" - ) - assert isinstance(first, PresenceScheduleRecord) - assert first.child_id == child.id - second = await schedules.upsert_by_child( - child.id, 2, "2026-09-14", "1,3|0,2,4" - ) - assert second.id == first.id, "upsert must reuse the existing row" - fetched = await schedules.get_by_child(child.id) - assert fetched.anchor_date == "2026-09-14" - assert fetched.pattern == "1,3|0,2,4" - rows = await database.fetch_one( - "SELECT COUNT(*) FROM presence_schedules" - ) - assert rows == (1,) - return second - - _with_db(tmp_path, "upsert.db")(_body) - - -def test_upsert_typed_record_fields(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): - record = await schedules.upsert_by_child( - child.id, 4, "2026-09-07", "0|1|2|3" +def test_create_returns_typed_record_fields(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + record = await patterns.create( + child.id, "Mum's rota", "home", 4, "2026-09-07", "0|1|2|3" ) + assert isinstance(record, PresencePatternRecord) assert record.id == 1 assert record.child_id == child.id + assert record.name == "Mum's rota" + assert record.kind == "home" assert record.cycle_length_weeks == 4 assert record.anchor_date == "2026-09-07" assert record.pattern == "0|1|2|3" + assert await patterns.get(record.id) == record return record - _with_db(tmp_path, "upsert-fields.db")(_body) + _with_db(tmp_path, "create-fields.db")(_body) -def test_upsert_unknown_child_raises_value_error(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): - with pytest.raises(ValueError, match="does not exist"): - await schedules.upsert_by_child( - 999, 2, "2026-09-07", "0,2|1,3" +def test_create_many_patterns_for_one_child(tmp_path) -> None: + """Schema 9 drops the one-per-child UNIQUE: a second create for the + same child adds a row instead of raising or replacing.""" + async def _body(database, patterns, overrides, children, child): + first = await patterns.create( + child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3" + ) + second = await patterns.create( + child.id, "Thu/Fri away", "away", 1, "2026-09-07", "3,4" + ) + third = await patterns.create( + child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3" + ) + assert len({first.id, second.id, third.id}) == 3 + listed = await patterns.list_by_child(child.id) + assert listed == [first, second, third] + rows = await database.fetch_one( + "SELECT COUNT(*) FROM presence_patterns" + ) + assert rows == (3,) + return listed + + _with_db(tmp_path, "create-many.db")(_body) + + +def test_create_unknown_child_raises_value_error(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + with pytest.raises(ValueError, match="child 999 does not exist"): + await patterns.create( + 999, "Home", "home", 2, "2026-09-07", "0,2|1,3" ) return None - _with_db(tmp_path, "upsert-bad-child.db")(_body) + _with_db(tmp_path, "create-bad-child.db")(_body) -def test_upsert_malformed_anchor_date_raises(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): +def test_create_malformed_anchor_date_raises(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""): with pytest.raises(ValueError, match="YYYY-MM-DD"): - await schedules.upsert_by_child(child.id, 2, bad, "0|1") + await patterns.create(child.id, "Home", "home", 2, bad, "0|1") + assert await patterns.list_by_child(child.id) == [] return None - _with_db(tmp_path, "upsert-bad-date.db")(_body) + _with_db(tmp_path, "create-bad-date.db")(_body) -def test_upsert_malformed_pattern_rejected_by_schema(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): +def test_create_malformed_pattern_rejected_by_schema(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): with pytest.raises(sqlite3.IntegrityError, match="CHECK"): - await schedules.upsert_by_child( - child.id, 2, "2026-09-07", "0,2" + await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2" ) return None - _with_db(tmp_path, "upsert-bad-pattern.db")(_body) + _with_db(tmp_path, "create-bad-pattern.db")(_body) -def test_upsert_two_children_have_distinct_rows(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): +@pytest.mark.parametrize("bad_kind", ["present", "Home", "AWAY", ""]) +def test_create_unknown_kind_rejected_by_schema(tmp_path, bad_kind) -> None: + async def _body(database, patterns, overrides, children, child): + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + await patterns.create( + child.id, "Home", bad_kind, 1, "2026-09-07", "0" + ) + assert await patterns.list_by_child(child.id) == [] + return None + + _with_db(tmp_path, "create-bad-kind.db")(_body) + + +def test_list_by_child_two_children_are_isolated(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): other = await children.create("Bo", NOW) - await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3") - await schedules.upsert_by_child(other.id, 2, "2026-09-07", "1,3|0,2") - mine = await schedules.get_by_child(child.id) - theirs = await schedules.get_by_child(other.id) - assert mine.id != theirs.id - assert mine.pattern == "0,2|1,3" - assert theirs.pattern == "1,3|0,2" + await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" + ) + await patterns.create( + other.id, "Home", "home", 2, "2026-09-07", "1,3|0,2" + ) + mine = await patterns.list_by_child(child.id) + theirs = await patterns.list_by_child(other.id) + assert [r.child_id for r in mine] == [child.id] + assert [r.child_id for r in theirs] == [other.id] + assert mine[0].id != theirs[0].id + assert mine[0].pattern == "0,2|1,3" + assert theirs[0].pattern == "1,3|0,2" return mine - _with_db(tmp_path, "upsert-two-children.db")(_body) + _with_db(tmp_path, "list-two-children.db")(_body) -def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: - """Racing upserts for one child both succeed; exactly one row. +def test_list_by_child_orders_by_id(tmp_path) -> None: + """Ordering is by id (creation order), not by name, kind or anchor.""" + async def _body(database, patterns, overrides, children, child): + created = [ + await patterns.create( + child.id, "Zed", "away", 1, "2026-09-21", "6" + ), + await patterns.create( + child.id, "Alpha", "home", 1, "2026-09-07", "0" + ), + await patterns.create( + child.id, "Mid", "away", 2, "2026-09-14", "1|2" + ), + ] + listed = await patterns.list_by_child(child.id) + assert [r.id for r in listed] == sorted(r.id for r in created) + assert [r.name for r in listed] == ["Zed", "Alpha", "Mid"] + return listed + + _with_db(tmp_path, "list-order.db")(_body) + + +def test_create_concurrent_same_child_both_land(tmp_path) -> None: + """Racing creates for one child both succeed as DISTINCT rows. Deterministic interleaving: a controllable executor gates task A's job AFTER it has acquired the connection lock and begun its transaction, so task B is created while A provably owns the - transaction and must queue on the lock. Both upserts land, and - the table holds exactly one row (the ON CONFLICT upsert, not two - plain INSERTs, is what avoids a UNIQUE violation). + transaction and must queue on the lock. With no UNIQUE on + ``child_id`` (schema 9) both INSERTs land and the child lists two + patterns in id order. """ async def _main(): from unittest.mock import MagicMock @@ -180,13 +227,6 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: a_started = asyncio.Event() async def _gated_executor(fn, *args): - # Gate the SECOND gated-window job: the first is the - # transaction's BEGIN (submitted and awaited by - # transaction() before upsert_by_child's child-SELECT - # runs). Pausing the child-existence SELECT proves the - # BEGIN has EXECUTED — SQLite is actually inside the open - # transaction, with A holding the connection lock — when - # task B gets scheduled and must queue. if armed["active"]: name = getattr(fn, "__name__", "") if name == "_fetch_one" and not armed["gated"]: @@ -197,17 +237,17 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: hass.async_add_executor_job = _gated_executor database = NestQuestDatabase(hass.async_add_executor_job) - await database.open(tmp_path / "upsert-race.db") + await database.open(tmp_path / "create-race.db") try: await apply_migrations(database) children = ChildrenDao(database) - schedules = PresenceSchedulesDao(database) + patterns = PresencePatternsDao(database) child = await children.create("Ada", NOW) armed["active"] = True task_a = asyncio.ensure_future( - schedules.upsert_by_child( - child.id, 2, "2026-09-07", "0,2|1,3" + patterns.create( + child.id, "A", "home", 2, "2026-09-07", "0,2|1,3" ) ) # Wait until task A's child-existence SELECT (inside its @@ -217,81 +257,197 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None: await a_started.wait() armed["active"] = False task_b = asyncio.ensure_future( - schedules.upsert_by_child( - child.id, 2, "2026-09-14", "1,3|0,2" + patterns.create( + child.id, "B", "away", 1, "2026-09-14", "5,6" ) ) await asyncio.sleep(0) - # Release A; B's upsert must wait for A's commit and then - # land as the ON CONFLICT update path. + # Release A; B must wait for A's commit and then insert its + # own row. gate_open.set() record_a, record_b = await asyncio.gather(task_a, task_b) - assert record_a.id == record_b.id - fetched = await schedules.get_by_child(child.id) - assert fetched is not None + assert record_a.id != record_b.id + assert (record_a.name, record_b.name) == ("A", "B") + listed = await patterns.list_by_child(child.id) rows = await database.fetch_one( - "SELECT COUNT(*) FROM presence_schedules" + "SELECT COUNT(*) FROM presence_patterns" ) - assert rows == (1,) - return fetched + assert rows == (2,) + return record_a, record_b, listed finally: await database.close() - fetched = _run(_main()) - assert fetched.pattern in {"0,2|1,3", "1,3|0,2"} + record_a, record_b, listed = _run(_main()) + assert listed == [record_a, record_b] # --------------------------------------------------------------------------- -# presence_schedules: get / delete +# presence_patterns: list / get / update / delete # --------------------------------------------------------------------------- -def test_get_by_child_missing_schedule_returns_none(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): - return await schedules.get_by_child(child.id) +def test_list_by_child_without_patterns_returns_empty(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + return await patterns.list_by_child(child.id) + + assert _with_db(tmp_path, "list-none.db")(_body) == [] + + +def test_list_by_child_unknown_child_raises(tmp_path) -> None: + """[] must mean ONLY 'no pattern rows': an unknown child id is a + caller bug, and silently reading a nonexistent child as 'present + every day' would be wrong — so list raises instead. + """ + async def _body(database, patterns, overrides, children, child): + with pytest.raises(ValueError, match="child 999 does not exist"): + await patterns.list_by_child(999) + return None + + _with_db(tmp_path, "list-unknown.db")(_body) + + +def test_get_missing_pattern_returns_none(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + return await patterns.get(999) assert _with_db(tmp_path, "get-none.db")(_body) is None -def test_get_by_child_unknown_child_raises(tmp_path) -> None: - """None must mean ONLY 'no schedule row': an unknown child id is a - caller bug, and silently reading a nonexistent child as 'present - every day' would be wrong — so get raises instead. - """ - async def _body(database, schedules, overrides, children, child): - with pytest.raises(ValueError, match="does not exist"): - await schedules.get_by_child(999) +def test_update_rewrites_every_field_but_child(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + record = await patterns.create( + child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3" + ) + updated = await patterns.update( + record.id, "Weekends away", "away", 1, "2026-09-12", "5,6" + ) + assert updated == PresencePatternRecord( + id=record.id, + child_id=child.id, + name="Weekends away", + kind="away", + cycle_length_weeks=1, + anchor_date="2026-09-12", + pattern="5,6", + ) + assert await patterns.get(record.id) == updated + assert await patterns.list_by_child(child.id) == [updated] + return updated + + _with_db(tmp_path, "update.db")(_body) + + +def test_update_touches_only_the_addressed_pattern(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + first = await patterns.create( + child.id, "Home", "home", 1, "2026-09-07", "0,1,2" + ) + second = await patterns.create( + child.id, "Away", "away", 1, "2026-09-07", "5" + ) + updated = await patterns.update( + second.id, "Away", "away", 1, "2026-09-07", "5,6" + ) + assert await patterns.list_by_child(child.id) == [first, updated] + return updated + + _with_db(tmp_path, "update-one.db")(_body) + + +def test_update_unknown_id_returns_none(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + result = await patterns.update( + 999, "Home", "home", 1, "2026-09-07", "0" + ) + assert await patterns.list_by_child(child.id) == [] + return result + + assert _with_db(tmp_path, "update-unknown.db")(_body) is None + + +def test_update_malformed_anchor_date_raises(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + record = await patterns.create( + child.id, "Home", "home", 1, "2026-09-07", "0" + ) + for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""): + with pytest.raises(ValueError, match="YYYY-MM-DD"): + await patterns.update(record.id, "Home", "home", 1, bad, "0") + assert await patterns.get(record.id) == record return None - _with_db(tmp_path, "get-unknown.db")(_body) + _with_db(tmp_path, "update-bad-date.db")(_body) + + +def test_update_bad_kind_and_pattern_rejected_by_schema(tmp_path) -> None: + """The CHECKs apply on UPDATE exactly as on INSERT, and a rejected + update leaves the stored row unchanged.""" + async def _body(database, patterns, overrides, children, child): + record = await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" + ) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + await patterns.update( + record.id, "Home", "present", 2, "2026-09-07", "0,2|1,3" + ) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + await patterns.update( + record.id, "Home", "home", 2, "2026-09-07", "0,2" + ) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + await patterns.update( + record.id, "Home", "home", 5, "2026-09-07", "0|1|2|3|4" + ) + assert await patterns.get(record.id) == record + return None + + _with_db(tmp_path, "update-bad-check.db")(_body) def test_delete_removes_and_returns_true_then_false(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): - await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3") - assert await schedules.delete(child.id) is True - assert await schedules.get_by_child(child.id) is None - assert await schedules.delete(child.id) is False + async def _body(database, patterns, overrides, children, child): + record = await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" + ) + assert await patterns.delete(record.id) is True + assert await patterns.get(record.id) is None + assert await patterns.list_by_child(child.id) == [] + assert await patterns.delete(record.id) is False return None _with_db(tmp_path, "delete.db")(_body) -def test_delete_child_without_schedule_returns_false(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): - return await schedules.delete(child.id) +def test_delete_missing_pattern_returns_false(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + return await patterns.delete(999) assert _with_db(tmp_path, "delete-none.db")(_body) is False -def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): - first = await schedules.upsert_by_child( - child.id, 2, "2026-09-07", "0,2|1,3" +def test_delete_leaves_the_childs_other_patterns(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + first = await patterns.create( + child.id, "Home", "home", 1, "2026-09-07", "0,1,2" ) - await schedules.delete(child.id) - second = await schedules.upsert_by_child( - child.id, 2, "2026-09-07", "0,2|1,3" + second = await patterns.create( + child.id, "Away", "away", 1, "2026-09-07", "5" + ) + assert await patterns.delete(first.id) is True + assert await patterns.list_by_child(child.id) == [second] + return None + + _with_db(tmp_path, "delete-others.db")(_body) + + +def test_delete_then_create_makes_fresh_row(tmp_path) -> None: + async def _body(database, patterns, overrides, children, child): + first = await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" + ) + await patterns.delete(first.id) + second = await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" ) assert second.id != first.id return second @@ -305,7 +461,7 @@ def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None: def test_override_create_returns_typed_record(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): record = await overrides.create( child.id, "2026-12-21", "2027-01-04", False, note="Holiday abroad", @@ -322,7 +478,7 @@ def test_override_create_returns_typed_record(tmp_path) -> None: def test_override_create_single_day_same_start_and_end(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): record = await overrides.create( child.id, "2026-09-14", "2026-09-14", True ) @@ -335,7 +491,7 @@ def test_override_create_single_day_same_start_and_end(tmp_path) -> None: def test_override_create_unknown_child_raises(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): with pytest.raises(ValueError, match="does not exist"): await overrides.create( 999, "2026-09-14", "2026-09-14", True @@ -346,7 +502,7 @@ def test_override_create_unknown_child_raises(tmp_path) -> None: def test_override_create_malformed_dates_raise(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): for bad in ("not-a-date", "2026-9-14", ""): with pytest.raises(ValueError, match="YYYY-MM-DD"): await overrides.create(child.id, bad, "2026-09-14", True) @@ -358,7 +514,7 @@ def test_override_create_malformed_dates_raise(tmp_path) -> None: def test_override_end_before_start_rejected_by_schema(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): with pytest.raises(sqlite3.IntegrityError, match="CHECK"): await overrides.create( child.id, "2026-09-14", "2026-09-13", True @@ -371,7 +527,7 @@ def test_override_end_before_start_rejected_by_schema(tmp_path) -> None: def test_override_list_by_child_and_range_filters_correctly( tmp_path, ) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): await overrides.create( child.id, "2026-09-01", "2026-09-05", False, note="before" ) @@ -400,7 +556,7 @@ def test_override_list_by_child_and_range_filters_correctly( def test_override_list_orders_by_start_date(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): late = await overrides.create( child.id, "2026-09-20", "2026-09-21", True ) @@ -417,7 +573,7 @@ def test_override_list_orders_by_start_date(tmp_path) -> None: def test_override_list_per_child_isolation(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): other = await children.create("Bo", NOW) await overrides.create(child.id, "2026-09-10", "2026-09-11", True) await overrides.create(other.id, "2026-09-10", "2026-09-11", False) @@ -432,7 +588,7 @@ def test_override_list_per_child_isolation(tmp_path) -> None: def test_override_list_malformed_range_raises(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): with pytest.raises(ValueError, match="YYYY-MM-DD"): await overrides.list_by_child_and_range( child.id, "oops", "2026-09-30" @@ -447,7 +603,7 @@ def test_override_list_malformed_range_raises(tmp_path) -> None: def test_override_list_unknown_child_returns_empty(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): return await overrides.list_by_child_and_range( 999, "2026-09-01", "2026-09-30" ) @@ -456,7 +612,7 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None: def test_override_get_round_trip(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): record = await overrides.create( child.id, "2026-09-10", "2026-09-11", True, note="camp" ) @@ -470,7 +626,7 @@ def test_override_get_round_trip(tmp_path) -> None: def test_override_delete_round_trip(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): record = await overrides.create( child.id, "2026-09-10", "2026-09-11", True ) @@ -486,14 +642,14 @@ def test_override_delete_round_trip(tmp_path) -> None: def test_override_delete_missing_returns_false(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): return await overrides.delete(999) assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False def test_override_delete_leaves_other_overrides(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): first = await overrides.create( child.id, "2026-09-10", "2026-09-11", True ) @@ -510,18 +666,25 @@ def test_override_delete_leaves_other_overrides(tmp_path) -> None: _with_db(tmp_path, "override-delete-others.db")(_body) -def test_schedule_and_override_independent_for_same_child(tmp_path) -> None: - """Deleting the schedule does not touch overrides and vice versa.""" - async def _body(database, schedules, overrides, children, child): - await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3") +def test_pattern_and_override_independent_for_same_child(tmp_path) -> None: + """Deleting a pattern does not touch overrides and vice versa.""" + async def _body(database, patterns, overrides, children, child): + pattern = await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" + ) override = await overrides.create( child.id, "2026-09-10", "2026-09-11", True ) - assert await schedules.delete(child.id) is True + assert await patterns.delete(pattern.id) is True found = await overrides.list_by_child_and_range( child.id, "2026-09-01", "2026-09-30" ) assert [r.id for r in found] == [override.id] + second = await patterns.create( + child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3" + ) + assert await overrides.delete(override.id) is True + assert await patterns.list_by_child(child.id) == [second] return found _with_db(tmp_path, "independent.db")(_body) @@ -533,7 +696,7 @@ def test_schedule_and_override_independent_for_same_child(tmp_path) -> None: def test_presence_sql_lives_only_in_dao_module() -> None: - """Guardrail: presence_schedules/presence_overrides SQL may appear + """Guardrail: presence_patterns/presence_overrides SQL may appear only in the DAO module (and the schema DDL declarations). RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/ JOIN pattern, with exact repo-relative exclusions justified by @@ -562,7 +725,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None: } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?" - r"(presence_schedules|presence_overrides)\b", + r"(presence_patterns|presence_overrides)\b", re.IGNORECASE, ) offenders: list[str] = [] @@ -574,7 +737,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None: if sql_pattern.search(py.read_text()): offenders.append(relative) assert offenders == [], ( - f"SQL touching presence_schedules/presence_overrides leaked " + f"SQL touching presence_patterns/presence_overrides leaked " f"into: {offenders}" ) @@ -582,8 +745,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None: def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: """This test module must exercise the DAO, not raw SQL, for these tables. Exemptions are EXACT lines, not whole-function spans: the - two single-statement COUNT(*) row probes (the only honest way to - prove 'exactly one row exists' without a listing method) and the + single-statement COUNT(*) row probes (the only honest way to prove + how many rows the whole table holds, which a per-child listing + cannot) and the guard functions' own regex spans. Any other raw SQL added anywhere in this file fails the guard. """ @@ -615,7 +779,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: isinstance(sub, ast.Constant) and isinstance(sub.value, str) and sub.value.strip() - == "SELECT COUNT(*) FROM presence_schedules" + == "SELECT COUNT(*) FROM presence_patterns" and sub.lineno is not None ): excluded.add(sub.lineno) @@ -627,7 +791,8 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: ) sql_pattern = re.compile( r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(presence_schedules|presence_overrides)" + r"FROM|JOIN)\s+[`'\"]*(\[)?" + r"(presence_patterns|presence_overrides)" r"\b", re.IGNORECASE, ) @@ -641,7 +806,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None: def test_override_create_round_trip_single_and_range(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): single = await overrides.create( child.id, "2026-07-20", "2026-07-20", False ) @@ -662,7 +827,7 @@ def test_override_create_round_trip_single_and_range(tmp_path) -> None: def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): existing = await overrides.create( child.id, "2026-07-20", "2026-07-24", False, note="holiday" ) @@ -698,7 +863,7 @@ def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None: def test_override_overlap_is_per_child(tmp_path) -> None: """Different children's overrides may cover the same dates: the conflict rule is per child.""" - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): other = await children.create("Bo", NOW) await overrides.create(child.id, "2026-07-20", "2026-07-24", False) twin = await overrides.create( @@ -715,7 +880,7 @@ def test_override_overlap_is_per_child(tmp_path) -> None: def test_override_remove_then_recreate_overlapping(tmp_path) -> None: - async def _body(database, schedules, overrides, children, child): + async def _body(database, patterns, overrides, children, child): first = await overrides.create( child.id, "2026-07-20", "2026-07-24", False ) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 39a5667..829040d 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -12,7 +12,7 @@ from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao +from custom_components.nestquest.core.dao_presence import PresencePatternsDao from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, schedule_rule_to_storage, @@ -28,6 +28,10 @@ from custom_components.nestquest.core.quest_definitions import ( create_quest_definition, edit_quest_definition, ) +from custom_components.nestquest.core.presence_management import ( + create_presence_pattern, + delete_presence_pattern, +) from custom_components.nestquest.core.recurrence import ( RuleType, ScheduleRule, @@ -83,7 +87,7 @@ def _build_expected(start, def1_id, def2_id, a_id, b_id, c_id) -> set: due_time) tuples over the four-week window. Custody: A is present week 0 and 2 of the two-week cycle, absent - 1 and 3; B is the mirror; C has no schedule (present every day). + 1 and 3; B is the mirror; C has no pattern (present every day). """ expected: set = set() start_dt = datetime.date.fromisoformat(start) @@ -128,21 +132,21 @@ async def _collect_instances(database, child_ids, start, end) -> set: def test_materialize_two_custody_one_always_present(tmp_path) -> None: async def _body(database): children = ChildrenDao(database) - schedules = PresenceSchedulesDao(database) + patterns = PresencePatternsDao(database) a = await children.create("Ada", NOW) # present weeks 0, 2 b = await children.create("Bo", NOW) # present weeks 1, 3 - c = await children.create("Cleo", NOW) # no schedule: always present + c = await children.create("Cleo", NOW) # no pattern: always present start = _future_monday() start_iso = start.isoformat() end_iso = (start + datetime.timedelta(days=27)).isoformat() - await schedules.upsert_by_child( - a.id, 2, start_iso, "0,1,2,3,4,5,6|" + await patterns.create( + a.id, "Home schedule", "home", 2, start_iso, "0,1,2,3,4,5,6|" ) - await schedules.upsert_by_child( - b.id, 2, start_iso, "|0,1,2,3,4,5,6" + await patterns.create( + b.id, "Home schedule", "home", 2, start_iso, "|0,1,2,3,4,5,6" ) def1 = await create_quest_definition( @@ -181,6 +185,136 @@ def test_materialize_two_custody_one_always_present(tmp_path) -> None: _with_db(tmp_path, "materialize.db")(_body) +async def _due_dates(database, child_id, start_iso, end_iso) -> set[str]: + records = await QuestInstancesDao(database).list_by_date_range( + child_id, start_iso, end_iso + ) + return {record.due_date for record in records} + + +def test_materialize_home_and_away_patterns_combine(tmp_path) -> None: + """Several patterns per child combine: a covering ``away`` beats a + covering ``home``. An every-day home pattern plus an away Thu+Fri + pattern leaves the child absent exactly on Thursday and Friday, so a + skip_on_away definition skips those days while a non-skipping one + still generates them.""" + async def _body(database): + child = await ChildrenDao(database).create("Ada", NOW) + start = _future_monday() + start_iso = start.isoformat() + end_iso = (start + datetime.timedelta(days=6)).isoformat() + patterns = PresencePatternsDao(database) + await patterns.create( + child.id, "Home schedule", "home", 1, start_iso, "0,1,2,3,4,5,6" + ) + await patterns.create( + child.id, "Thu-Fri away", "away", 1, start_iso, "3,4" + ) + skipping = await create_quest_definition( + database, + "Skipping chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + skip_on_away=True, + ) + keeping = await create_quest_definition( + database, + "Keeping chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["evening"], + skip_on_away=False, + ) + + count = await materialize(database, start_iso, end_iso) + assert count == 5 + 7 + + records = await QuestInstancesDao(database).list_by_date_range( + child.id, start_iso, end_iso + ) + by_definition: dict[int, set[str]] = {} + for record in records: + by_definition.setdefault(record.definition_id, set()).add( + record.due_date + ) + thursday_friday = { + (start + datetime.timedelta(days=offset)).isoformat() + for offset in (3, 4) + } + all_week = { + (start + datetime.timedelta(days=offset)).isoformat() + for offset in range(7) + } + assert by_definition[skipping.definition.id] == ( + all_week - thursday_friday + ) + assert by_definition[keeping.definition.id] == all_week + + _with_db(tmp_path, "materialize-multi-pattern.db")(_body) + + +def test_presence_pattern_create_and_delete_regenerate_instances( + tmp_path, +) -> None: + """Creating and deleting patterns re-materializes the child's future. + + Home Mon..Wed plus away Tuesday leaves Monday and Wednesday; deleting + the away pattern restores Tuesday; deleting the last (home) pattern + makes the child present every day again. + """ + async def _body(database): + child = await ChildrenDao(database).create("Ada", NOW) + start = _future_monday() + start_iso = start.isoformat() + end_iso = (start + datetime.timedelta(days=6)).isoformat() + await create_quest_definition( + database, + "Daily chore", + ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), + [child.id], + ["morning"], + ) + window = {"today": start, "horizon_days": 6} + + def days(*offsets: int) -> set[str]: + return { + (start + datetime.timedelta(days=offset)).isoformat() + for offset in offsets + } + + home = await create_presence_pattern( + database, child.id, "Home schedule", "home", 1, start_iso, + {0: {0, 1, 2}}, **window, + ) + assert await _due_dates(database, child.id, start_iso, end_iso) == ( + days(0, 1, 2) + ) + away = await create_presence_pattern( + database, child.id, "Tuesday away", "away", 1, start_iso, + {0: {1}}, **window, + ) + assert await _due_dates(database, child.id, start_iso, end_iso) == ( + days(0, 2) + ) + + deleted = await delete_presence_pattern(database, away.id, **window) + assert deleted == away + assert await _due_dates(database, child.id, start_iso, end_iso) == ( + days(0, 1, 2) + ) + + await delete_presence_pattern(database, home.id, **window) + assert await PresencePatternsDao(database).list_by_child( + child.id + ) == [] + assert await _due_dates(database, child.id, start_iso, end_iso) == ( + days(*range(7)) + ) + + _with_db(tmp_path, "materialize-pattern-delete.db")(_body) + + def test_materialize_shares_one_generated_at_per_run(tmp_path) -> None: async def _body(database): children = ChildrenDao(database) @@ -638,7 +772,7 @@ def test_materialize_honours_overrides(tmp_path) -> None: start = _future_monday() start_iso = start.isoformat() end_iso = (start + datetime.timedelta(days=6)).isoformat() - # No schedule means the child is present every day; a blocking + # No presence pattern means the child is present every day; a blocking # override covering the whole week must still suppress every day. await overrides.create( child.id, start_iso, end_iso, False, note="away all week" @@ -1623,27 +1757,38 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None: from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) - from custom_components.nestquest.core.presence import PresenceSchedule + from custom_components.nestquest.core.presence import PresencePattern children = ChildrenDao(database) - schedules = PresenceSchedulesDao(database) + patterns = PresencePatternsDao(database) overrides = PresenceOverridesDao(database) a = await children.create("Ada", NOW) # present weeks 0, 2 b = await children.create("Bo", NOW) # present weeks 1, 3 - c = await children.create("Cleo", NOW) # no schedule: always present + c = await children.create("Cleo", NOW) # no pattern: always present anchor = datetime.date.fromisoformat(_E2E_ANCHOR) start_iso = anchor.isoformat() end_iso = (anchor + datetime.timedelta(days=27)).isoformat() # Two-week custody with OPPOSITE weeks, encoded through the - # PresenceSchedule model on the fixed Monday anchor. + # PresencePattern model (one ``home`` pattern each) on the fixed + # Monday anchor. all_week = frozenset(range(7)) - a_schedule = PresenceSchedule(a.id, 2, anchor, {0: all_week, 1: frozenset()}) - b_schedule = PresenceSchedule(b.id, 2, anchor, {0: frozenset(), 1: all_week}) - await schedules.upsert_by_child(a.id, 2, start_iso, a_schedule.encode()) - await schedules.upsert_by_child(b.id, 2, start_iso, b_schedule.encode()) + a_home = PresencePattern( + a.id, "Home schedule", "home", 2, anchor, + {0: all_week, 1: frozenset()}, + ) + b_home = PresencePattern( + b.id, "Home schedule", "home", 2, anchor, + {0: frozenset(), 1: all_week}, + ) + await patterns.create( + a.id, a_home.name, a_home.kind, 2, start_iso, a_home.encode() + ) + await patterns.create( + b.id, b_home.name, b_home.kind, 2, start_iso, b_home.encode() + ) def_ids = {} def_ids["daily"] = (await create_quest_definition( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 855164a..61dd613 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import datetime import hashlib import logging import sqlite3 @@ -12,6 +13,9 @@ import pytest from custom_components.nestquest.core.db import NestQuestDatabase from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL from custom_components.nestquest.core.migrations import ( + MIGRATED_PRESENCE_PATTERN_NAME, + MIGRATION_1_V1_DDL, + MIGRATION_9_PRESENCE_PATTERNS, MIGRATIONS, VERSION_TABLE, VERSION_TABLE_DDL, @@ -69,7 +73,7 @@ def _counts(database) -> dict[str, int]: "quest_definitions", "quest_definition_assignees", "quest_definition_windows", - "presence_schedules", + "presence_patterns", "presence_overrides", "quest_instances", "completion_events", @@ -107,7 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None: "quest_definitions", "quest_definition_assignees", "quest_definition_windows", - "presence_schedules", + "presence_patterns", "presence_overrides", "quest_instances", "completion_events", @@ -154,10 +158,26 @@ def test_migration_list_shape() -> None: model, migration 4 adds the windows table, migration 5 rebuilds quest_instances onto the widened (definition, child, date, window) key, migration 6 adds actor_child_id to completion_events, - migration 7 adds the meta_state table and migration 8 adds - skip_on_away to quest_definitions. + migration 7 adds the meta_state table, migration 8 adds + skip_on_away to quest_definitions and migration 9 retires + presence_schedules for presence_patterns. + + Migration 1 is the v1 DDL as it shipped: the canonical statements + except that it still creates the retired presence_schedules table + where the canonical list now creates presence_patterns. """ - assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS + assert MIGRATIONS[0] is MIGRATION_1_V1_DDL + assert len(MIGRATION_1_V1_DDL) == len(SCHEMA_V1_STATEMENTS) + differing = [ + (shipped, canonical) + for shipped, canonical in zip(MIGRATION_1_V1_DDL, SCHEMA_V1_STATEMENTS) + if shipped != canonical + ] + assert len(differing) == 1 + shipped, canonical = differing[0] + assert "CREATE TABLE IF NOT EXISTS presence_schedules" in shipped + assert "CREATE TABLE IF NOT EXISTS presence_patterns" in canonical + assert not any("presence_patterns" in sql for sql in MIGRATION_1_V1_DDL) assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[2]) assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL @@ -165,7 +185,8 @@ def test_migration_list_shape() -> None: assert callable(MIGRATIONS[5]) assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL assert callable(MIGRATIONS[7]) - assert len(MIGRATIONS) == 8 + assert MIGRATIONS[8] is MIGRATION_9_PRESENCE_PATTERNS + assert len(MIGRATIONS) == 9 # --------------------------------------------------------------------------- @@ -474,7 +495,8 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: """ # Freeze the version-2 shape: definitions carry child_id, the # instance table has no window column, and the assignees/windows - # tables do not exist yet — everything else matches the current DDL. + # tables do not exist yet — everything else matches the shipped v1 + # DDL (migration 1, which still creates presence_schedules). _v2_replaced = ( "quest_definitions ", "quest_definition_assignees ", @@ -482,7 +504,7 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: "quest_instances ", "completion_events ", ) - for sql in SCHEMA_V1_STATEMENTS: + for sql in MIGRATION_1_V1_DDL: if any(name in sql for name in _v2_replaced): continue _run(database.execute(sql)) @@ -694,7 +716,7 @@ def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None: before = _version(database) assert before == 7 - final = _migrate(database) + final = _migrate(database, MIGRATIONS[:8]) assert final == before + 1 assert _version(database) == before + 1 @@ -728,6 +750,311 @@ def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None: _run(database.close()) +# --------------------------------------------------------------------------- +# Migration 9: presence_schedules -> presence_patterns +# --------------------------------------------------------------------------- + +#: Pre-migration presence_schedules rows: (id, child_id, cycle, anchor, +#: pattern). Covers a 1-, 2- and 4-week cycle, an anchor that is not a +#: Monday, and the all-absent empty pattern; child 4 has no schedule. +_V8_SCHEDULES = ( + (1, 1, 2, "2026-01-05", "0,1,2,3,4|5,6"), + (2, 2, 1, "2025-12-31", "0,2,4"), + (5, 3, 4, "2024-02-26", "0|1,2|3,4,5|6"), + (7, 5, 1, "2026-03-01", ""), +) + +#: Pre-migration overrides: they must keep beating the migrated patterns. +_V8_OVERRIDES = ( + (1, "2026-02-02", "2026-02-08", 1), + (4, "2026-02-10", "2026-02-11", 0), +) + + +def _seed_v8_database(database) -> None: + """Build a genuine version-8 database holding presence schedules. + + Migrations 1..8 run first — migration 1 still creates the retired + presence_schedules table — then five children, the schedules and + overrides above are written through the old table's column list. + """ + assert _migrate(database, MIGRATIONS[:8]) == 8 + tables = _tables(database) + assert "presence_schedules" in tables + assert "presence_patterns" not in tables + for child_id in range(1, 6): + _run( + database.execute( + "INSERT INTO children (id, display_name, created_at) " + "VALUES (?, ?, '2026-01-01T00:00:00+00:00')", + (child_id, f"Child {child_id}"), + ) + ) + for row in _V8_SCHEDULES: + _run( + database.execute( + "INSERT INTO presence_schedules (id, child_id, " + "cycle_length_weeks, anchor_date, pattern) " + "VALUES (?, ?, ?, ?, ?)", + row, + ) + ) + for row in _V8_OVERRIDES: + _run( + database.execute( + "INSERT INTO presence_overrides (child_id, start_date, " + "end_date, is_present) VALUES (?, ?, ?, ?)", + row, + ) + ) + + +def _v8_presence(database, child_id: int, day: datetime.date) -> bool: + """Presence as a version-8 build answered it, read from the OLD rows. + + The retired single-schedule rule, spelled out independently of the + new engine: a covering override wins; a child with no schedule row + is present; otherwise present iff the weekday is in the segment of + the anchor-arithmetic cycle week. + """ + iso = day.isoformat() + override = _run( + database.fetch_one( + "SELECT is_present FROM presence_overrides WHERE child_id = ? " + "AND start_date <= ? AND end_date >= ? " + "ORDER BY start_date DESC LIMIT 1", + (child_id, iso, iso), + ) + ) + if override is not None: + return bool(override[0]) + row = _run( + database.fetch_one( + "SELECT cycle_length_weeks, anchor_date, pattern " + "FROM presence_schedules WHERE child_id = ?", + (child_id,), + ) + ) + if row is None: + return True + cycle, anchor, pattern = row + days = (day - datetime.date.fromisoformat(anchor)).days + segment = pattern.split("|")[(days // 7) % cycle] + return str(day.weekday()) in segment.split(",") + + +def _v9_engine(database): + """Build the new PresenceEngine from the migrated tables.""" + from custom_components.nestquest.core.dao_presence import ( + PresenceOverridesDao, + PresencePatternsDao, + ) + from custom_components.nestquest.core.presence import ( + PresenceEngine, + PresenceOverride, + ) + from custom_components.nestquest.core.presence_management import ( + decode_pattern_record, + ) + + patterns = {} + overrides = {} + for child_id in range(1, 6): + records = _run(PresencePatternsDao(database).list_by_child(child_id)) + if records: + patterns[child_id] = [decode_pattern_record(r) for r in records] + stored = _run( + PresenceOverridesDao(database).list_filtered(child_id=child_id) + ) + if stored: + overrides[child_id] = [ + PresenceOverride( + r.child_id, r.start_date, r.end_date, r.is_present + ) + for r in stored + ] + return PresenceEngine(patterns, overrides) + + +#: Every day from 2023-12-25 through 2027-01-10: before and after each +#: anchor, across three year boundaries and the 2024 leap day. +_COMPARE_DAYS = [ + datetime.date(2023, 12, 25) + datetime.timedelta(days=offset) + for offset in range( + (datetime.date(2027, 1, 10) - datetime.date(2023, 12, 25)).days + 1 + ) +] + + +def test_v8_presence_is_unchanged_after_migration_9(tmp_path) -> None: + """Presence for a pre-migration fixture is identical after migrating. + + The old table is built and filled, every child's presence is + recorded day by day under the version-8 rule, the database is + migrated, and the new engine must answer the SAME for every child + (including the one with no schedule and the all-absent one) on + every day of the three-year window. + """ + database = _open_db(tmp_path / "v8-presence.db") + try: + _seed_v8_database(database) + before = { + (child_id, day): _v8_presence(database, child_id, day) + for child_id in range(1, 6) + for day in _COMPARE_DAYS + } + # The fixture must exercise both answers for the scheduled + # children, or "unchanged" would be vacuous. + for child_id in (1, 2, 3): + answers = { + before[(child_id, day)] for day in _COMPARE_DAYS + } + assert answers == {True, False} + + assert _migrate(database) == 9 + + engine = _v9_engine(database) + after = { + (child_id, day): engine.is_present(child_id, day) + for child_id in range(1, 6) + for day in _COMPARE_DAYS + } + changed = [key for key in before if before[key] != after[key]] + assert changed == [] + finally: + _run(database.close()) + + +def test_migration_9_copies_every_schedule_verbatim(tmp_path) -> None: + database = _open_db(tmp_path / "v8-copy.db") + try: + _seed_v8_database(database) + assert _migrate(database) == 9 + + tables = _tables(database) + assert "presence_schedules" not in tables + assert "presence_patterns" in tables + rows = _run( + database.fetch_all( + "SELECT id, child_id, name, kind, cycle_length_weeks, " + "anchor_date, pattern FROM presence_patterns ORDER BY id" + ) + ) + assert rows == [ + ( + schedule_id, + child_id, + MIGRATED_PRESENCE_PATTERN_NAME, + "home", + cycle, + anchor, + pattern, + ) + for schedule_id, child_id, cycle, anchor, pattern in _V8_SCHEDULES + ] + assert MIGRATED_PRESENCE_PATTERN_NAME == "Home schedule" + assert _counts(database)["presence_overrides"] == len(_V8_OVERRIDES) + assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] + finally: + _run(database.close()) + + +def test_migrated_database_accepts_several_patterns_per_child( + tmp_path, +) -> None: + """The migrated table drops the retired UNIQUE(child_id).""" + database = _open_db(tmp_path / "v8-multi.db") + try: + _seed_v8_database(database) + _migrate(database) + _run( + database.execute( + "INSERT INTO presence_patterns (child_id, name, kind, " + "cycle_length_weeks, anchor_date, pattern) " + "VALUES (1, 'Thu+Fri away', 'away', 1, '2026-01-05', '3,4')" + ) + ) + row = _run( + database.fetch_one( + "SELECT COUNT(*) FROM presence_patterns WHERE child_id = 1" + ) + ) + assert row == (2,) + finally: + _run(database.close()) + + +def test_fresh_file_walks_through_and_retires_presence_schedules( + tmp_path, +) -> None: + """Migration 1 still makes the old table; migration 9 retires it.""" + database = _open_db(tmp_path / "fresh-v9.db") + try: + assert _migrate(database, MIGRATIONS[:8]) == 8 + assert "presence_schedules" in _tables(database) + assert "presence_patterns" not in _tables(database) + + assert _migrate(database) == 9 + assert "presence_schedules" not in _tables(database) + assert _counts(database)["presence_patterns"] == 0 + finally: + _run(database.close()) + + +def test_migrated_presence_patterns_match_the_canonical_ddl(tmp_path) -> None: + """A migrated table and one made from schema.py are the same shape.""" + migrated = _open_db(tmp_path / "migrated-shape.db") + canonical = _open_db(tmp_path / "canonical-shape.db") + try: + _migrate(migrated) + for sql in SCHEMA_V1_STATEMENTS: + _run(canonical.execute(sql)) + for pragma in ("table_info", "foreign_key_list", "index_list"): + assert _run( + migrated.fetch_all(f"PRAGMA {pragma}(presence_patterns)") + ) == _run( + canonical.fetch_all(f"PRAGMA {pragma}(presence_patterns)") + ) + finally: + _run(migrated.close()) + _run(canonical.close()) + + +def test_failed_migration_9_keeps_the_schedules(tmp_path) -> None: + """Migration 9 is atomic: a failure after the copy loses nothing. + + The real migration 9 statements are followed by a broken one in + the SAME step, so the create, the copy and the drop have all run + when it fails — the runner's one transaction must roll every one + of them back together with the stamp. + """ + database = _open_db(tmp_path / "v8-atomic.db") + try: + _seed_v8_database(database) + broken = [ + *MIGRATIONS[:8], + [*MIGRATION_9_PRESENCE_PATTERNS, "CREATE TABLE broken ("], + ] + with pytest.raises(sqlite3.OperationalError): + _migrate(database, broken) + + assert _version(database) == 8 + tables = _tables(database) + assert "presence_patterns" not in tables + rows = _run( + database.fetch_all( + "SELECT id, child_id, cycle_length_weeks, anchor_date, " + "pattern FROM presence_schedules ORDER BY id" + ) + ) + assert rows == list(_V8_SCHEDULES) + + assert _migrate(database) == 9 + assert _counts(database)["presence_patterns"] == len(_V8_SCHEDULES) + finally: + _run(database.close()) + + # --------------------------------------------------------------------------- # Idempotence: second start is a no-op # --------------------------------------------------------------------------- diff --git a/tests/test_presence.py b/tests/test_presence.py index fd17270..c731e96 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -7,133 +7,173 @@ import pytest from custom_components.nestquest.core.presence import ( MAX_CYCLE_LENGTH_WEEKS, + PATTERN_KINDS, PresenceEngine, PresenceOverride, - PresenceSchedule, + PresencePattern, cycle_week_index, ) ANCHOR = datetime.date(2026, 1, 5) # a Monday +#: Every pre-schema-9 single schedule is exactly one ``home`` pattern +#: (migration 9 carries them across that way), so the model and engine +#: tests below use this name/kind unless they are about name or kind. +NAME = "Home schedule" +HOME = "home" + TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}} # --------------------------------------------------------------------------- -# PresenceSchedule construction +# PresencePattern construction # --------------------------------------------------------------------------- -def test_schedule_holds_validated_fields() -> None: - schedule = PresenceSchedule( - 3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}} +def test_pattern_holds_validated_fields() -> None: + pattern = PresencePattern( + 3, "Dad's weekends", "away", 2, "2026-01-05", + {0: {0, 2, 4}, 1: {1, 3}}, ) - assert schedule.child_id == 3 - assert schedule.cycle_length_weeks == 2 - assert schedule.anchor_date == ANCHOR - assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})} + assert pattern.child_id == 3 + assert pattern.name == "Dad's weekends" + assert pattern.kind == "away" + assert pattern.cycle_length_weeks == 2 + assert pattern.anchor_date == ANCHOR + assert pattern.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})} -def test_schedule_accepts_date_objects() -> None: - schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) - assert schedule.anchor_date is ANCHOR +def test_pattern_accepts_date_objects() -> None: + pattern = PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}}) + assert pattern.anchor_date is ANCHOR -def test_schedule_rejects_cycle_length_below_one() -> None: +def test_pattern_name_is_stored_trimmed() -> None: + pattern = PresencePattern(1, " Mum's week \t", HOME, 1, ANCHOR, {0: {0}}) + assert pattern.name == "Mum's week" + + +@pytest.mark.parametrize("bad", ["", " ", "\t\n"]) +def test_pattern_rejects_blank_name(bad) -> None: + with pytest.raises(ValueError, match="name must not be empty"): + PresencePattern(1, bad, HOME, 1, ANCHOR, {0: {0}}) + + +@pytest.mark.parametrize("bad", [None, 5, b"Home", ["Home"]]) +def test_pattern_rejects_non_string_name(bad) -> None: + with pytest.raises(ValueError, match="name must be a string"): + PresencePattern(1, bad, HOME, 1, ANCHOR, {0: {0}}) + + +def test_pattern_kinds_are_home_and_away() -> None: + """The model's kinds match the schema's CHECK exactly.""" + assert PATTERN_KINDS == ("home", "away") + for kind in PATTERN_KINDS: + assert PresencePattern(1, NAME, kind, 1, ANCHOR, {0: {0}}).kind == kind + + +@pytest.mark.parametrize("bad", ["Home", "AWAY", "present", "", None, True, 1]) +def test_pattern_rejects_unknown_kind(bad) -> None: + with pytest.raises(ValueError, match="kind must be one of home, away"): + PresencePattern(1, NAME, bad, 1, ANCHOR, {0: {0}}) + + +def test_pattern_rejects_cycle_length_below_one() -> None: with pytest.raises(ValueError, match="between 1 and"): - PresenceSchedule(1, 0, ANCHOR, {}) + PresencePattern(1, NAME, HOME, 0, ANCHOR, {}) with pytest.raises(ValueError, match="between 1 and"): - PresenceSchedule(1, -1, ANCHOR, {}) + PresencePattern(1, NAME, HOME, -1, ANCHOR, {}) -def test_schedule_rejects_cycle_length_above_documented_cap() -> None: +def test_pattern_rejects_cycle_length_above_documented_cap() -> None: """The 4 cap matches the schema's CHECK — both documents agree.""" with pytest.raises(ValueError, match="between 1 and"): - PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {}) + PresencePattern(1, NAME, HOME, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {}) assert MAX_CYCLE_LENGTH_WEEKS == 4 @pytest.mark.parametrize("bad", [True, "2", 1.0, None]) -def test_schedule_rejects_non_int_cycle_length(bad) -> None: +def test_pattern_rejects_non_int_cycle_length(bad) -> None: with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"): - PresenceSchedule(1, bad, ANCHOR, {0: {0}}) + PresencePattern(1, NAME, HOME, bad, ANCHOR, {0: {0}}) -def test_schedule_rejects_pattern_missing_week_indices() -> None: +def test_pattern_rejects_pattern_missing_week_indices() -> None: with pytest.raises(ValueError, match="missing \\[1\\]"): - PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}}) + PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}}) -def test_schedule_rejects_pattern_with_extra_week_indices() -> None: +def test_pattern_rejects_pattern_with_extra_week_indices() -> None: with pytest.raises(ValueError, match="week index must be in 0"): - PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}}) + PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}}) -def test_schedule_rejects_pattern_with_non_int_week_keys() -> None: +def test_pattern_rejects_pattern_with_non_int_week_keys() -> None: with pytest.raises(ValueError, match="week index must be an integer"): - PresenceSchedule(1, 1, ANCHOR, {"0": {0}}) + PresencePattern(1, NAME, HOME, 1, ANCHOR, {"0": {0}}) -def test_schedule_rejects_pattern_with_bool_week_key() -> None: +def test_pattern_rejects_pattern_with_bool_week_key() -> None: """True would silently be week 1; bools are never valid indices.""" with pytest.raises(ValueError, match="week index must be an integer"): - PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}}) + PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, True: {1}}) @pytest.mark.parametrize("bad_weekday", [-1, 7, 100]) -def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None: +def test_pattern_rejects_weekday_outside_zero_to_six(bad_weekday) -> None: with pytest.raises(ValueError, match="weekday must be in 0..6"): - PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}}) + PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {bad_weekday}}) @pytest.mark.parametrize("bad", [True, "1", 1.5, None]) -def test_schedule_rejects_non_int_weekdays(bad) -> None: +def test_pattern_rejects_non_int_weekdays(bad) -> None: with pytest.raises(ValueError, match="weekday must be an int"): - PresenceSchedule(1, 1, ANCHOR, {0: [bad]}) + PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: [bad]}) -def test_schedule_rejects_string_and_none_week_sets() -> None: +def test_pattern_rejects_string_and_none_week_sets() -> None: """A string is iterable — "02" would silently mean {0, 2}.""" with pytest.raises(ValueError, match="iterable of weekday ints"): - PresenceSchedule(1, 1, ANCHOR, {0: "02"}) + PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: "02"}) with pytest.raises(ValueError, match="iterable of weekday ints"): - PresenceSchedule(1, 1, ANCHOR, {0: None}) + PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: None}) -def test_schedule_rejects_non_dict_pattern() -> None: +def test_pattern_rejects_non_dict_pattern() -> None: with pytest.raises(ValueError, match="must map week index"): - PresenceSchedule(1, 1, ANCHOR, "0,2") + PresencePattern(1, NAME, HOME, 1, ANCHOR, "0,2") @pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None]) -def test_schedule_rejects_non_int_child_id(bad) -> None: +def test_pattern_rejects_non_int_child_id(bad) -> None: with pytest.raises(ValueError, match="child_id must be an integer"): - PresenceSchedule(bad, 1, ANCHOR, {0: {0}}) + PresencePattern(bad, NAME, HOME, 1, ANCHOR, {0: {0}}) @pytest.mark.parametrize( "bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True] ) -def test_schedule_rejects_non_strict_anchor_dates(bad) -> None: +def test_pattern_rejects_non_strict_anchor_dates(bad) -> None: with pytest.raises(ValueError, match="anchor_date"): - PresenceSchedule(1, 1, bad, {0: {0}}) + PresencePattern(1, NAME, HOME, 1, bad, {0: {0}}) -def test_schedule_empty_week_means_absent_week() -> None: +def test_pattern_empty_week_means_absent_week() -> None: """An explicitly empty week is valid: absent every day of it — - distinct from having no schedule (the engine's default).""" - schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) - assert schedule.pattern[1] == frozenset() + distinct from having no pattern (the engine's default).""" + pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert pattern.pattern[1] == frozenset() -def test_schedule_empty_week_encodes_as_empty_segment() -> None: - schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()}) - assert schedule.encode() == "0,2|" +def test_pattern_empty_week_encodes_as_empty_segment() -> None: + pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()}) + assert pattern.encode() == "0,2|" -def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None: - one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}}) - two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}}) +def test_pattern_equal_patterns_are_equal_and_encode_identically() -> None: + one = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}}) + two = PresencePattern(2, NAME, HOME, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}}) assert one.encode() == two.encode() == "0,2,4|1,3" @@ -154,34 +194,60 @@ def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None: "0,1,2,3,4,5,6|", ], ) -def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None: - schedule = PresenceSchedule.decode(1, ANCHOR, encoded) - assert schedule.encode() == encoded - again = PresenceSchedule.decode(1, ANCHOR, schedule.encode()) - assert again == schedule +def test_pattern_decode_encode_round_trips_losslessly(encoded) -> None: + pattern = PresencePattern.decode(1, NAME, HOME, ANCHOR, encoded) + assert pattern.encode() == encoded + again = PresencePattern.decode(1, NAME, HOME, ANCHOR, pattern.encode()) + assert again == pattern -def test_schedule_decode_sets_cycle_length_from_segments() -> None: - one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4") +def test_pattern_decode_sets_cycle_length_from_segments() -> None: + one_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0,2,4") assert one_week.cycle_length_weeks == 1 - four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4") + four_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0|1|2|3,4") assert four_week.cycle_length_weeks == 4 -def test_schedule_decode_rejects_malformed_segments() -> None: +def test_pattern_decode_rejects_malformed_segments() -> None: for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"): with pytest.raises(ValueError, match="invalid weekday"): - PresenceSchedule.decode(1, ANCHOR, bad) + PresencePattern.decode(1, NAME, HOME, ANCHOR, bad) -def test_schedule_decode_rejects_non_string() -> None: +def test_pattern_decode_rejects_non_string() -> None: with pytest.raises(ValueError, match="must be a string"): - PresenceSchedule.decode(1, ANCHOR, None) + PresencePattern.decode(1, NAME, HOME, ANCHOR, None) -def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None: +def test_pattern_decode_rejects_too_many_segments(tmp_path) -> None: with pytest.raises(ValueError, match="between 1 and"): - PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4") + PresencePattern.decode(1, NAME, HOME, ANCHOR, "0|1|2|3|4") + + +def test_pattern_decode_carries_and_validates_name_and_kind() -> None: + """decode is ordinary construction: name trimmed, kind checked.""" + decoded = PresencePattern.decode(1, " Away days ", "away", ANCHOR, "3,4") + assert (decoded.name, decoded.kind) == ("Away days", "away") + with pytest.raises(ValueError, match="name must not be empty"): + PresencePattern.decode(1, " ", "away", ANCHOR, "3,4") + with pytest.raises(ValueError, match="kind must be one of"): + PresencePattern.decode(1, NAME, "absent", ANCHOR, "3,4") + + +def test_pattern_covers_follows_the_cycle_week() -> None: + """covers() is the weekday-in-this-cycle-week test, independent of + kind: an away pattern covers its days exactly like a home one.""" + for kind in PATTERN_KINDS: + pattern = PresencePattern(1, NAME, kind, 2, ANCHOR, {0: {1, 3}, 1: {2}}) + # Tue Jan 6: week 0, weekday 1 -> covered. + assert pattern.covers(datetime.date(2026, 1, 6)) is True + # Wed Jan 7: week 0, weekday 2 -> not covered. + assert pattern.covers(datetime.date(2026, 1, 7)) is False + # Wed Jan 14: week 1, weekday 2 -> covered. + assert pattern.covers(datetime.date(2026, 1, 14)) is True + # Tue Dec 30 2025: before the anchor, walks back into week 1. + assert pattern.covers(datetime.date(2025, 12, 30)) is False + assert pattern.covers(datetime.date(2025, 12, 31)) is True # --------------------------------------------------------------------------- @@ -231,11 +297,11 @@ def test_override_rejects_non_strict_dates(start, end) -> None: PresenceOverride(1, start, end, True) -def test_schedule_rejects_datetime_anchor() -> None: +def test_pattern_rejects_datetime_anchor() -> None: """datetime.datetime subclasses date: a bare isinstance check would store a time component and every date comparison would TypeError.""" with pytest.raises(ValueError, match="plain calendar date"): - PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}}) + PresencePattern(1, NAME, HOME, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}}) def test_override_rejects_datetime_bounds() -> None: @@ -245,23 +311,23 @@ def test_override_rejects_datetime_bounds() -> None: ) -def test_schedule_pattern_is_immutable_after_construction() -> None: - """The frozen schedule must not leak a mutable dict: injecting +def test_pattern_pattern_is_immutable_after_construction() -> None: + """The frozen pattern must not leak a mutable dict: injecting unvalidated weekdays or dropping required weeks after construction would bypass total validation.""" - schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}}) + pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: {1}}) with pytest.raises(TypeError): - schedule.pattern[0] = {9} + pattern.pattern[0] = {9} with pytest.raises(TypeError): - del schedule.pattern[1] + del pattern.pattern[1] # mappingproxy exposes no mutators at all. assert not any( - hasattr(schedule.pattern, name) + hasattr(pattern.pattern, name) for name in ("clear", "pop", "popitem", "setdefault", "update") ) # The validated contents are still readable and encode correctly. - assert schedule.pattern[0] == frozenset({0, 2}) - assert schedule.encode() == "0,2|1" + assert pattern.pattern[0] == frozenset({0, 2}) + assert pattern.encode() == "0,2|1" # --------------------------------------------------------------------------- @@ -270,13 +336,13 @@ def test_schedule_pattern_is_immutable_after_construction() -> None: def test_cycle_week_index_follows_the_anchor_formula() -> None: - schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) + pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}}) # The anchor day starts week 0; each whole 7 days steps the cycle. offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1} for offset, expected in offsets.items(): assert ( cycle_week_index( - schedule, ANCHOR + datetime.timedelta(days=offset) + pattern, ANCHOR + datetime.timedelta(days=offset) ) == expected ), f"offset {offset}" @@ -285,18 +351,18 @@ def test_cycle_week_index_follows_the_anchor_formula() -> None: def test_cycle_week_index_before_the_anchor_walks_backwards() -> None: """Python floor division: day -1 is the LAST week of the cycle, wrapping backwards — never inverting.""" - schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}}) - assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1 - assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1 - assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0 + pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}}) + assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=1)) == 1 + assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=7)) == 1 + assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=8)) == 0 def test_cycle_week_index_three_week_pattern() -> None: - schedule = PresenceSchedule( - 1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}} + pattern = PresencePattern( + 1, NAME, HOME, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}} ) offsets = [0, 7, 14, 21, 28] - assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [ + assert [cycle_week_index(pattern, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [ 0, 1, 2, 0, 1 ] @@ -307,8 +373,8 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None: or +1 (mod cycle length) and NEVER resets or inverts — including 2020, a 53-ISO-week year, where ISO week parity flips and would invert a two-week custody schedule on January 1st.""" - schedule = PresenceSchedule( - 1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} + pattern = PresencePattern( + 1, NAME, HOME, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}} ) # Independently derived expectations (anchor 2019-01-06, index = # floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so @@ -357,7 +423,7 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None: datetime.date(2022, 1, 7): 0, } for day, wanted in expected.items(): - got = cycle_week_index(schedule, day) + got = cycle_week_index(pattern, day) assert got == wanted, f"{day}: expected week {wanted}, got {got}" # And 2020 really is the 53-ISO-week year this guards against. assert datetime.date(2020, 12, 31).isocalendar()[1] == 53 @@ -388,21 +454,22 @@ def test_module_uses_no_iso_week_functions() -> None: def _three_child_engine() -> PresenceEngine: - """Two children on opposite weeks of one two-week rotation, one - with no schedule at all (the always-present sibling).""" + """Two children on opposite weeks of one two-week rotation (each a + single ``home`` pattern), one with no pattern at all (the + always-present sibling).""" anchor = datetime.date(2026, 1, 5) # Monday, week 0 - declan = PresenceSchedule( - 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + declan = PresencePattern( + 1, NAME, HOME, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} ) - jordyn = PresenceSchedule( - 2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}} + jordyn = PresencePattern( + 2, NAME, HOME, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}} ) - return PresenceEngine({1: declan, 2: jordyn}) + return PresenceEngine({1: [declan], 2: [jordyn]}) -def test_engine_child_without_schedule_is_always_present() -> None: +def test_engine_child_without_pattern_is_always_present() -> None: """Every day of the four-week span, all three children get the - expected verdict: Chloe (no schedule) always present; the two + expected verdict: Chloe (no pattern) always present; the two rotation children complementary per the anchor pattern.""" engine = _three_child_engine() anchor = datetime.date(2026, 1, 5) @@ -446,9 +513,12 @@ def test_engine_weekend_absence_respects_the_pattern() -> None: child's present week.""" engine = PresenceEngine( { - 1: PresenceSchedule( - 1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}} - ) + 1: [ + PresencePattern( + 1, NAME, HOME, 2, datetime.date(2026, 1, 5), + {0: {1, 3}, 1: {2, 4}}, + ) + ] } ) # 2026-01-06 (Tue) is week 0, weekday 1 -> present. @@ -460,18 +530,24 @@ def test_engine_weekend_absence_respects_the_pattern() -> None: def test_engine_rejects_bad_construction_and_inputs() -> None: - schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + pattern = PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}}) with pytest.raises(ValueError, match="must map child_id"): - PresenceEngine([schedule]) - with pytest.raises(ValueError, match="must be a PresenceSchedule"): - PresenceEngine({1: "not-a-schedule"}) - with pytest.raises(ValueError, match="holds the schedule"): - PresenceEngine({2: schedule}) + PresenceEngine([pattern]) + # Values are LISTS now: a bare pattern (the retired one-schedule + # shape) is refused rather than silently iterated. + with pytest.raises(ValueError, match="must be a list of PresencePattern"): + PresenceEngine({1: pattern}) + with pytest.raises(ValueError, match="must be a list of PresencePattern"): + PresenceEngine({1: (pattern,)}) + with pytest.raises(ValueError, match="entries must be PresencePattern"): + PresenceEngine({1: ["not-a-pattern"]}) + with pytest.raises(ValueError, match="holds a pattern of child 1"): + PresenceEngine({2: [pattern]}) with pytest.raises(ValueError, match="child_id must be an integer"): - PresenceEngine({True: schedule}) + PresenceEngine({True: [pattern]}) with pytest.raises(ValueError, match="child_id must be an integer"): - PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})}) - engine = PresenceEngine({1: schedule}) + PresenceEngine({1.0: [PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})]}) + engine = PresenceEngine({1: [pattern]}) with pytest.raises(ValueError, match="child_id must be an integer"): engine.is_present(True, ANCHOR) with pytest.raises(ValueError, match="plain calendar date"): @@ -481,11 +557,111 @@ def test_engine_rejects_bad_construction_and_inputs() -> None: def test_engine_is_immutable() -> None: engine = _three_child_engine() with pytest.raises(AttributeError, match="immutable snapshot"): - engine._schedules = {} + engine._patterns = {} with pytest.raises(AttributeError, match="immutable snapshot"): engine.something_new = 1 +# --------------------------------------------------------------------------- +# PresenceEngine: combining several patterns per child +# --------------------------------------------------------------------------- + + +def _pattern(kind: str, pattern: dict, *, cycle: int = 1) -> PresencePattern: + return PresencePattern(1, f"{kind} pattern", kind, cycle, ANCHOR, pattern) + + +def test_engine_empty_pattern_list_is_always_present() -> None: + """An empty list is the same as no entry: present every day.""" + engine = PresenceEngine({1: []}) + for offset in range(14): + day = ANCHOR + datetime.timedelta(days=offset) + assert engine.is_present(1, day) is True, f"offset {offset}" + + +def test_engine_away_only_child_is_present_except_covered_days() -> None: + """Away patterns alone never make an uncovered day absent: the + household default (present) holds outside their coverage.""" + engine = PresenceEngine({1: [_pattern("away", {0: {3, 4}})]}) + for offset in range(14): + day = ANCHOR + datetime.timedelta(days=offset) + expected = day.weekday() not in {3, 4} + assert engine.is_present(1, day) is expected, f"{day}" + + +def test_engine_several_home_patterns_union_their_days() -> None: + """Any covering home pattern makes the day present; a day no home + pattern covers is absent once the child has a home pattern.""" + engine = PresenceEngine( + { + 1: [ + _pattern("home", {0: {0, 1}}), + _pattern("home", {0: {4}, 1: set()}, cycle=2), + ] + } + ) + # Week 0 (Jan 5-11): Mon, Tue from the first, Fri from the second. + present = { + ANCHOR + datetime.timedelta(days=offset) + for offset in range(7) + if engine.is_present(1, ANCHOR + datetime.timedelta(days=offset)) + } + assert present == { + datetime.date(2026, 1, 5), + datetime.date(2026, 1, 6), + datetime.date(2026, 1, 9), + } + # Week 1: the second pattern's empty week leaves only Mon/Tue. + assert engine.is_present(1, datetime.date(2026, 1, 16)) is False + assert engine.is_present(1, datetime.date(2026, 1, 12)) is True + + +def test_engine_away_pattern_beats_covering_home_pattern() -> None: + """Rule order: a covering away pattern wins over a covering home + pattern, whichever is listed first.""" + home = _pattern("home", {0: {0, 1, 2, 3, 4, 5, 6}}) + away = _pattern("away", {0: {5, 6}}) + for patterns in ([home, away], [away, home]): + engine = PresenceEngine({1: patterns}) + assert engine.is_present(1, datetime.date(2026, 1, 9)) is True # Fri + assert engine.is_present(1, datetime.date(2026, 1, 10)) is False # Sat + assert engine.is_present(1, datetime.date(2026, 1, 11)) is False # Sun + + +def test_engine_home_pattern_makes_uncovered_days_absent_even_with_away() -> None: + """Mixing kinds: a day neither pattern covers is absent because the + child has a home pattern (rule 4), not present.""" + engine = PresenceEngine( + {1: [_pattern("home", {0: {0, 1}}), _pattern("away", {0: {5}})]} + ) + assert engine.is_present(1, datetime.date(2026, 1, 5)) is True # Mon + assert engine.is_present(1, datetime.date(2026, 1, 7)) is False # Wed + assert engine.is_present(1, datetime.date(2026, 1, 10)) is False # Sat + + +def test_engine_override_beats_away_pattern() -> None: + engine = PresenceEngine( + {1: [_pattern("away", {0: {5, 6}})]}, + overrides={ + 1: [PresenceOverride(1, "2026-01-10", "2026-01-10", True)] + }, + ) + assert engine.is_present(1, datetime.date(2026, 1, 10)) is True + assert engine.is_present(1, datetime.date(2026, 1, 11)) is False + + +def test_engine_pattern_lists_are_frozen() -> None: + """Mutating the caller's pattern list after construction must not + change the engine's answers.""" + patterns = [_pattern("home", {0: {0}})] + engine = PresenceEngine({1: patterns}) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + patterns.append(_pattern("home", {0: {1}})) + assert engine.is_present(1, datetime.date(2026, 1, 6)) is False + with pytest.raises(AttributeError): + engine._patterns[1].append(patterns[1]) + + # --------------------------------------------------------------------------- # Overrides take precedence over the pattern # --------------------------------------------------------------------------- @@ -495,9 +671,12 @@ def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine: anchor = datetime.date(2026, 1, 5) # Monday return PresenceEngine( { - 1: PresenceSchedule( - 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} - ) + 1: [ + PresencePattern( + 1, NAME, HOME, 2, anchor, + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + ] }, overrides={1: list(overrides_for_declan)} if overrides_for_declan else {}, ) @@ -523,8 +702,8 @@ def test_present_override_on_normally_absent_day_wins() -> None: assert engine.is_present(1, datetime.date(2026, 1, 14)) is False -def test_override_applies_without_a_schedule() -> None: - """An override on a schedule-free child still applies: holidays and +def test_override_applies_without_a_pattern() -> None: + """An override on a pattern-free child still applies: holidays and swaps must work for the always-present sibling too.""" engine = PresenceEngine( {}, @@ -554,18 +733,18 @@ def test_override_range_beats_pattern_for_its_span() -> None: def test_engine_override_snapshot_validation() -> None: - schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}}) + patterns = [PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})] entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True) with pytest.raises(ValueError, match="must map child_id"): - PresenceEngine({1: schedule}, overrides=[entry]) + PresenceEngine({1: patterns}, overrides=[entry]) with pytest.raises(ValueError, match="must be a list"): - PresenceEngine({1: schedule}, overrides={1: entry}) + PresenceEngine({1: patterns}, overrides={1: entry}) with pytest.raises(ValueError, match="must be"): - PresenceEngine({1: schedule}, overrides={1: ["nope"]}) + PresenceEngine({1: patterns}, overrides={1: ["nope"]}) with pytest.raises(ValueError, match="holds an override"): - PresenceEngine({1: schedule}, overrides={2: [entry]}) + PresenceEngine({1: patterns}, overrides={2: [entry]}) with pytest.raises(ValueError, match="child_id must be an integer"): - PresenceEngine({1: schedule}, overrides={True: [entry]}) + PresenceEngine({1: patterns}, overrides={True: [entry]}) def test_engine_overlapping_snapshot_uses_latest_start() -> None: @@ -591,11 +770,11 @@ def test_engine_override_lists_are_frozen() -> None: frozen snapshot it must not.""" entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False) entries = [entry] - schedule = PresenceSchedule( - 1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + pattern = PresencePattern( + 1, NAME, HOME, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} ) engine = PresenceEngine( - {1: schedule}, overrides={1: entries} # caller-owned list + {1: [pattern]}, overrides={1: entries} # caller-owned list ) assert engine.is_present(1, datetime.date(2026, 1, 6)) is False entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False)) @@ -620,10 +799,12 @@ def _preview_engine() -> PresenceEngine: would matter only for the override test.""" return PresenceEngine( { - 1: PresenceSchedule( - 1, 2, datetime.date(2026, 1, 5), - {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, - ) + 1: [ + PresencePattern( + 1, NAME, HOME, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + ] } ) @@ -669,12 +850,12 @@ def test_preview_start_date_is_inclusive() -> None: def test_preview_honours_overrides() -> None: - schedule = PresenceSchedule( - 1, 2, datetime.date(2026, 1, 5), + pattern = PresencePattern( + 1, NAME, HOME, 2, datetime.date(2026, 1, 5), {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, ) engine = PresenceEngine( - {1: schedule}, + {1: [pattern]}, overrides={ 1: [ PresenceOverride( @@ -692,7 +873,7 @@ def test_preview_honours_overrides() -> None: ] -def test_preview_schedule_free_child_returns_consecutive_days() -> None: +def test_preview_pattern_free_child_returns_consecutive_days() -> None: engine = PresenceEngine({}) preview = engine.next_present_dates(3, "2026-03-01", 3) assert preview == [ @@ -719,9 +900,11 @@ def test_preview_always_absent_child_raises_not_loops() -> None: hang the admin preview.""" engine = PresenceEngine( { - 1: PresenceSchedule( - 1, 1, datetime.date(2026, 1, 5), {0: set()} - ) + 1: [ + PresencePattern( + 1, NAME, HOME, 1, datetime.date(2026, 1, 5), {0: set()} + ) + ] } ) with pytest.raises(ValueError, match="not present on any"): @@ -767,10 +950,10 @@ def test_custody_rotation_walks_full_years_without_inversion( days=(7 - datetime.date(year, 1, 1).weekday()) % 7 ) assert anchor.weekday() == 0 - rotation = PresenceSchedule( - 1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} + rotation = PresencePattern( + 1, NAME, HOME, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()} ) - engine = PresenceEngine({1: rotation}) + engine = PresenceEngine({1: [rotation]}) # Walk EVERY day of the year. The expectation is derived from the # anchor's 7-day BLOCKS, not from the engine: each block of seven diff --git a/tests/test_presence_patterns.py b/tests/test_presence_patterns.py new file mode 100644 index 0000000..f63732c --- /dev/null +++ b/tests/test_presence_patterns.py @@ -0,0 +1,165 @@ +"""Tests for the schema-9 multi-pattern presence engine. + +A child carries zero or more :class:`PresencePattern` rules; the +engine resolves them override -> away -> home -> default. The +expectations below are written out literally, week by week, rather +than recomputed with the engine's own arithmetic, so a wrong cycle +position cannot agree with itself. +""" +from __future__ import annotations + +import datetime + +from custom_components.nestquest.core.presence import ( + PresenceEngine, + PresenceOverride, + PresencePattern, +) + +CHILD = 1 + +#: The owner's rules, both anchored on Monday 2026-09-07 (cycle week 0). +AWAY_THU_FRI = PresencePattern( + CHILD, "Thursdays and Fridays away", "away", 1, "2026-09-07", {0: {3, 4}} +) +AWAY_ALTERNATE_WEEKENDS = PresencePattern( + CHILD, "Weekends away", "away", 2, "2026-09-07", {0: {5, 6}, 1: set()} +) + + +def _owner_engine(overrides=None) -> PresenceEngine: + return PresenceEngine( + {CHILD: [AWAY_THU_FRI, AWAY_ALTERNATE_WEEKENDS]}, overrides + ) + + +def _week(engine: PresenceEngine, monday: str) -> str: + """Render one Monday..Sunday week as ``P`` (present) / ``A`` (away).""" + start = datetime.date.fromisoformat(monday) + assert start.weekday() == 0, monday + return "".join( + "P" if engine.is_present(CHILD, start + datetime.timedelta(days=d)) + else "A" + for d in range(7) + ) + + +def test_owner_scenario_day_by_day_across_several_weeks() -> None: + """Away Thu+Fri every week, plus away Sat+Sun every other week.""" + engine = _owner_engine() + # Mon..Sun + expected = { + "2026-08-31": "PPPAAPP", # cycle week 1, before the anchor + "2026-09-07": "PPPAAAA", # anchor week: weekend away + "2026-09-14": "PPPAAPP", + "2026-09-21": "PPPAAAA", + "2026-09-28": "PPPAAPP", + "2026-10-05": "PPPAAAA", + "2026-10-12": "PPPAAPP", + "2026-10-19": "PPPAAAA", + } + assert {monday: _week(engine, monday) for monday in expected} == expected + + +def test_owner_scenario_across_the_year_boundary() -> None: + """2026 has 53 ISO weeks; the alternation must not skip a beat.""" + engine = _owner_engine() + expected = { + "2026-12-21": "PPPAAPP", # Dec 26-27 home + "2026-12-28": "PPPAAAA", # Jan 1 (Fri) and Jan 2-3 away + "2027-01-04": "PPPAAPP", # Jan 9-10 home + "2027-01-11": "PPPAAAA", # Jan 16-17 away + } + assert {monday: _week(engine, monday) for monday in expected} == expected + + +def test_owner_scenario_through_a_leap_day() -> None: + """February 29th 2028 is an ordinary Tuesday in the cycle.""" + engine = _owner_engine() + expected = { + "2028-02-21": "PPPAAAA", # Feb 26-27 away + "2028-02-28": "PPPAAPP", # Tue Feb 29 home; Mar 4-5 home + "2028-03-06": "PPPAAAA", # Mar 11-12 away + } + assert {monday: _week(engine, monday) for monday in expected} == expected + assert engine.is_present(CHILD, datetime.date(2028, 2, 29)) is True + + +def test_cycle_position_ignores_iso_week_parity() -> None: + """D-004: consecutive Saturdays in odd ISO weeks 53 and 1 alternate. + + 2027-01-02 sits in ISO week 53 of 2026 and 2027-01-09 in ISO week 1 + of 2027 — both odd, so a week-parity rule would give them the SAME + answer. Anchor-date arithmetic puts them in consecutive cycle + weeks, so the alternate-weekend pattern covers exactly one. + """ + first = datetime.date(2027, 1, 2) + second = datetime.date(2027, 1, 9) + assert first.isocalendar()[1] % 2 == second.isocalendar()[1] % 2 == 1 + assert AWAY_ALTERNATE_WEEKENDS.covers(first) is True + assert AWAY_ALTERNATE_WEEKENDS.covers(second) is False + engine = _owner_engine() + assert engine.is_present(CHILD, first) is False + assert engine.is_present(CHILD, second) is True + + +def test_child_with_no_patterns_is_present_every_day() -> None: + engine = _owner_engine() + other = 2 + start = datetime.date(2026, 12, 1) + for offset in range(120): + assert engine.is_present(other, start + datetime.timedelta(days=offset)) + empty = PresenceEngine({}) + assert all( + empty.is_present(CHILD, start + datetime.timedelta(days=offset)) + for offset in range(120) + ) + + +def test_overrides_beat_patterns_both_ways() -> None: + engine = _owner_engine( + { + CHILD: [ + # A Thursday the away pattern covers: present override. + PresenceOverride(CHILD, "2026-09-10", "2026-09-10", True), + # A Monday no pattern covers: absent override. + PresenceOverride(CHILD, "2026-09-14", "2026-09-14", False), + ] + } + ) + assert engine.is_present(CHILD, datetime.date(2026, 9, 10)) is True + assert engine.is_present(CHILD, datetime.date(2026, 9, 14)) is False + # The day after each override the patterns answer again. + assert engine.is_present(CHILD, datetime.date(2026, 9, 11)) is False + assert engine.is_present(CHILD, datetime.date(2026, 9, 15)) is True + + +def test_away_beats_home_where_both_cover_a_date() -> None: + home_every_day = PresencePattern( + CHILD, "Home schedule", "home", 1, "2026-09-07", + {0: {0, 1, 2, 3, 4, 5, 6}}, + ) + engine = PresenceEngine({CHILD: [home_every_day, AWAY_THU_FRI]}) + assert _week(engine, "2026-09-07") == "PPPAAPP" + # Order of the patterns does not matter. + reversed_engine = PresenceEngine({CHILD: [AWAY_THU_FRI, home_every_day]}) + assert _week(reversed_engine, "2026-09-07") == "PPPAAPP" + + +def test_home_patterns_mark_uncovered_days_away() -> None: + """A home pattern lists the days the child IS here (schedule semantics). + + With at least one home pattern, a date no pattern covers is away — + the retired single-schedule behaviour migration 9 preserves. Two + home patterns union their covered days. + """ + weekdays = PresencePattern( + CHILD, "School weeks", "home", 1, "2026-09-07", {0: {0, 1, 2}} + ) + alternate_weekends = PresencePattern( + CHILD, "Alternate weekends", "home", 2, "2026-09-07", + {0: {5, 6}, 1: set()}, + ) + engine = PresenceEngine({CHILD: [weekdays, alternate_weekends]}) + assert _week(engine, "2026-09-07") == "PPPAAPP" + assert _week(engine, "2026-09-14") == "PPPAAAA" diff --git a/tests/test_schema.py b/tests/test_schema.py index 86c6eaf..abd2263 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -13,7 +13,7 @@ from custom_components.nestquest.core.schema import ( SCHEMA_V1_COMPLETION_EVENTS_DDL, SCHEMA_V7_META_STATE_DDL, SCHEMA_V1_PRESENCE_OVERRIDES_DDL, - SCHEMA_V1_PRESENCE_SCHEDULES_DDL, + SCHEMA_V9_PRESENCE_PATTERNS_DDL, SCHEMA_V1_SCHEDULE_RULES_DDL, SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITIONS_DDL, @@ -112,14 +112,16 @@ def _insert_definition(database, rule_id=1, assignees=(1,), **overrides): return result.lastrowid -def _insert_schedule(database, child_id, **overrides): - """Insert one valid presence_schedule, returning the schedule id. +def _insert_pattern(database, child_id, **overrides): + """Insert one valid presence_pattern, returning the pattern id. Pass ``_OMIT`` as a value to leave that column out of the INSERT so its DDL DEFAULT applies. """ values: dict[str, object] = { "child_id": child_id, + "name": "Home schedule", + "kind": "home", "cycle_length_weeks": 2, "anchor_date": "2026-09-13", "pattern": "0,2,4|1,3", @@ -130,7 +132,7 @@ def _insert_schedule(database, child_id, **overrides): placeholders = ", ".join("?" for _ in values) result = _run( database.execute( - f"INSERT INTO presence_schedules ({columns}) VALUES ({placeholders})", + f"INSERT INTO presence_patterns ({columns}) VALUES ({placeholders})", tuple(values.values()), ) ) @@ -177,7 +179,7 @@ def test_schema_v1_statements_compose_the_nine_tables() -> None: *SCHEMA_V1_QUEST_DEFINITIONS_DDL, *SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL, *SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, - *SCHEMA_V1_PRESENCE_SCHEDULES_DDL, + *SCHEMA_V9_PRESENCE_PATTERNS_DDL, *SCHEMA_V1_PRESENCE_OVERRIDES_DDL, *SCHEMA_V1_QUEST_INSTANCES_DDL, *SCHEMA_V1_COMPLETION_EVENTS_DDL, @@ -1317,7 +1319,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None: # --------------------------------------------------------------------------- -# presence_schedules and presence_overrides constraint enforcement +# presence_patterns and presence_overrides constraint enforcement # --------------------------------------------------------------------------- @@ -1331,17 +1333,26 @@ def _child(database, name="Ada") -> int: return 1 -def test_presence_schedules_table_exists_after_applying_ddl(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules.db") +def test_presence_patterns_table_exists_after_applying_ddl(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns.db") try: _apply(database) row = _run( + database.fetch_one( + "SELECT name FROM sqlite_master WHERE type = 'table' " + "AND name = 'presence_patterns'" + ) + ) + assert row is not None and row[0] == "presence_patterns" + # Schema 9 replaced the one-per-child presence_schedules table; + # the current-schema DDL must not create it. + gone = _run( database.fetch_one( "SELECT name FROM sqlite_master WHERE type = 'table' " "AND name = 'presence_schedules'" ) ) - assert row is not None and row[0] == "presence_schedules" + assert gone is None finally: _run(database.close()) @@ -1361,15 +1372,17 @@ def test_presence_overrides_table_exists_after_applying_ddl(tmp_path) -> None: _run(database.close()) -def test_presence_schedules_columns_types_and_constraints(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules-columns.db") +def test_presence_patterns_columns_types_and_constraints(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-columns.db") try: _apply(database) - columns = _run(database.fetch_all("PRAGMA table_info(presence_schedules)")) + columns = _run(database.fetch_all("PRAGMA table_info(presence_patterns)")) # cid, name, type, notnull, dflt_value, pk assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [ ("id", "INTEGER", 0, None, 1), ("child_id", "INTEGER", 1, None, 0), + ("name", "TEXT", 1, None, 0), + ("kind", "TEXT", 1, None, 0), ("cycle_length_weeks", "INTEGER", 1, None, 0), ("anchor_date", "TEXT", 1, None, 0), ("pattern", "TEXT", 1, None, 0), @@ -1395,11 +1408,11 @@ def test_presence_overrides_columns_types_and_constraints(tmp_path) -> None: _run(database.close()) -def test_presence_schedules_foreign_key_declared(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules-fks.db") +def test_presence_patterns_foreign_key_declared(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-fks.db") try: _apply(database) - fks = _run(database.fetch_all("PRAGMA foreign_key_list(presence_schedules)")) + fks = _run(database.fetch_all("PRAGMA foreign_key_list(presence_patterns)")) declared = {(row[2], row[3], row[4]) for row in fks} assert declared == {("children", "child_id", "id")} finally: @@ -1417,48 +1430,119 @@ def test_presence_overrides_foreign_key_declared(tmp_path) -> None: _run(database.close()) -def test_presence_schedules_valid_insert_round_trips(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules-valid.db") +def test_presence_patterns_valid_insert_round_trips(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-valid.db") try: _apply(database) _child(database) - schedule_id = _insert_schedule(database, child_id=1) + pattern_id = _insert_pattern(database, child_id=1) row = _run( database.fetch_one( - "SELECT child_id, cycle_length_weeks, anchor_date, pattern " - "FROM presence_schedules WHERE id = ?", - (schedule_id,), + "SELECT child_id, name, kind, cycle_length_weeks, anchor_date, " + "pattern FROM presence_patterns WHERE id = ?", + (pattern_id,), ) ) - assert row == (1, 2, "2026-09-13", "0,2,4|1,3") + assert row == (1, "Home schedule", "home", 2, "2026-09-13", "0,2,4|1,3") finally: _run(database.close()) -def test_presence_schedules_second_schedule_for_same_child_fails(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules-unique.db") +def test_presence_patterns_allow_several_patterns_per_child(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-many.db") try: _apply(database) _child(database) - _insert_schedule(database, child_id=1) - with pytest.raises(sqlite3.IntegrityError, match="UNIQUE"): - _insert_schedule(database, child_id=1) + first = _insert_pattern(database, child_id=1) + second = _insert_pattern( + database, child_id=1, name="Weekend away", kind="away", + cycle_length_weeks=1, pattern="5,6", + ) + third = _insert_pattern(database, child_id=1) # identical row is fine + rows = _run( + database.fetch_all( + "SELECT id, name, kind FROM presence_patterns " + "WHERE child_id = 1 ORDER BY id" + ) + ) + assert rows == [ + (first, "Home schedule", "home"), + (second, "Weekend away", "away"), + (third, "Home schedule", "home"), + ] finally: _run(database.close()) -def test_presence_schedules_unknown_child_fails(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules-bad-child.db") +def test_presence_patterns_declare_no_unique_index(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-no-unique.db") + try: + _apply(database) + indexes = _run(database.fetch_all("PRAGMA index_list(presence_patterns)")) + assert [row for row in indexes if row[2]] == [] + finally: + _run(database.close()) + + +@pytest.mark.parametrize("bad_kind", ["", "HOME", "Away", "present", "absent", 1]) +def test_presence_patterns_kind_check_enforced(tmp_path, bad_kind) -> None: + database = _open_db(tmp_path / "presence-patterns-kind.db") + try: + _apply(database) + _child(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_pattern(database, child_id=1, kind=bad_kind) + finally: + _run(database.close()) + + +@pytest.mark.parametrize("kind", ["home", "away"]) +def test_presence_patterns_both_kinds_accepted(tmp_path, kind) -> None: + database = _open_db(tmp_path / f"presence-patterns-kind-{kind}.db") + try: + _apply(database) + _child(database) + pattern_id = _insert_pattern(database, child_id=1, kind=kind) + row = _run( + database.fetch_one( + "SELECT kind FROM presence_patterns WHERE id = ?", (pattern_id,) + ) + ) + assert row == (kind,) + finally: + _run(database.close()) + + +@pytest.mark.parametrize("column", ["name", "kind"]) +def test_presence_patterns_name_and_kind_not_null(tmp_path, column) -> None: + database = _open_db(tmp_path / f"presence-patterns-null-{column}.db") + try: + _apply(database) + _child(database) + with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"): + _insert_pattern(database, child_id=1, **{column: None}) + finally: + _run(database.close()) + + +def test_presence_schedules_absent_from_current_schema_ddl() -> None: + assert not any( + "presence_schedules" in sql for sql in SCHEMA_V1_STATEMENTS + ) + + +def test_presence_patterns_unknown_child_fails(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-bad-child.db") try: _apply(database) with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): - _insert_schedule(database, child_id=999) + _insert_pattern(database, child_id=999) finally: _run(database.close()) @pytest.mark.parametrize("bad_cycle", [0, 5, "abc", 1.5]) -def test_presence_schedules_cycle_length_out_of_range_or_type_fails( +def test_presence_patterns_cycle_length_out_of_range_or_type_fails( tmp_path, bad_cycle ) -> None: database = _open_db(tmp_path / f"cycle-{type(bad_cycle).__name__}.db") @@ -1466,7 +1550,7 @@ def test_presence_schedules_cycle_length_out_of_range_or_type_fails( _apply(database) _child(database) with pytest.raises(sqlite3.IntegrityError, match="CHECK"): - _insert_schedule(database, child_id=1, cycle_length_weeks=bad_cycle) + _insert_pattern(database, child_id=1, cycle_length_weeks=bad_cycle) finally: _run(database.close()) @@ -1487,18 +1571,18 @@ def test_presence_schedules_cycle_length_out_of_range_or_type_fails( (4, "0,1|0,1|0,1|0,1"), ], ) -def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) -> None: - database = _open_db(tmp_path / "presence-schedules-patterns-valid.db") +def test_presence_patterns_valid_patterns_succeed(tmp_path, cycle, pattern) -> None: + database = _open_db(tmp_path / "presence-patterns-patterns-valid.db") try: _apply(database) _child(database) - schedule_id = _insert_schedule( + pattern_id = _insert_pattern( database, child_id=1, cycle_length_weeks=cycle, pattern=pattern ) row = _run( database.fetch_one( - "SELECT pattern FROM presence_schedules WHERE id = ?", - (schedule_id,), + "SELECT pattern FROM presence_patterns WHERE id = ?", + (pattern_id,), ) ) assert row == (pattern,) @@ -1529,32 +1613,32 @@ def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) -> (1, "0,1,2,3,4,5,6,0"), # more than 7 elements ], ) -def test_presence_schedules_invalid_patterns_fail(tmp_path, cycle, pattern) -> None: - database = _open_db(tmp_path / "presence-schedules-patterns-invalid.db") +def test_presence_patterns_invalid_patterns_fail(tmp_path, cycle, pattern) -> None: + database = _open_db(tmp_path / "presence-patterns-patterns-invalid.db") try: _apply(database) _child(database) with pytest.raises(sqlite3.IntegrityError, match="CHECK"): - _insert_schedule( + _insert_pattern( database, child_id=1, cycle_length_weeks=cycle, pattern=pattern ) finally: _run(database.close()) -def test_presence_schedules_cycle_length_bounds_pass(tmp_path) -> None: - database = _open_db(tmp_path / "presence-schedules-cycle-bounds.db") +def test_presence_patterns_cycle_length_bounds_pass(tmp_path) -> None: + database = _open_db(tmp_path / "presence-patterns-cycle-bounds.db") try: _apply(database) _child(database) - _insert_schedule(database, child_id=1, cycle_length_weeks=1, pattern="0") + _insert_pattern(database, child_id=1, cycle_length_weeks=1, pattern="0") _run( database.execute( "INSERT INTO children (display_name, created_at) VALUES (?, ?)", ("Bo", "2026-09-13T00:00:00+00:00"), ) ) - _insert_schedule(database, child_id=2, cycle_length_weeks=4, pattern="|||") + _insert_pattern(database, child_id=2, cycle_length_weeks=4, pattern="|||") finally: _run(database.close()) @@ -1732,11 +1816,11 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None: try: _apply(database) _child(database) - _insert_schedule(database, child_id=1) + _insert_pattern(database, child_id=1) _insert_override(database, child_id=1) _apply(database) counts = [] - for table in ("presence_schedules", "presence_overrides"): + for table in ("presence_patterns", "presence_overrides"): row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}")) counts.append(row[0]) assert counts == [1, 1] -- 2.55.0 From 64a56389ad972dc286abd8b94d9b6104e3cf3c84 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Thu, 24 Sep 2026 20:41:06 -0400 Subject: [PATCH 3/7] Feature docs: multi-pattern presence, Sunday-first week and the migration-9 rollback (#234) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- deploy/RUNBOOK.md | 69 +++++++++++++++++++++++++++++++++ design/ADMIN-SPEC.md | 57 +++++++++++++++++++++------ design/ENTITIES-AND-SERVICES.md | 15 ++++++- design/IMPLEMENTATION-ORDER.md | 9 +++-- docs/ARCHITECTURE.md | 38 ++++++++++++++++++ 5 files changed, 173 insertions(+), 15 deletions(-) diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index c818852..c18d6f8 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -551,3 +551,72 @@ The file provider reloads the dynamic file on save. | 5 | Panel refused externally | section 9 check 4 | `403` | | 6 | Non-Traefik source refused | from a LAN machine other than `10.60.1.33`/`10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `403` (nginx `deny all`) | | 7 | Proxy source allowed | on `10.60.1.14`: `curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8081/` | `200` | + +## 11. Upgrades and rollback + +The API applies pending schema migrations when it starts (FastAPI +lifespan, before `/health` answers), so **the first start of a new +version is the point of no return** for any migration it carries. A +build refuses a database whose schema version is newer than it +understands ("newer than this integration understands … downgrades are +not supported") and does not start. + +### Migration 9 (multiple presence patterns) + +Migration 9 creates `presence_patterns`, copies every row of the old +one-per-child `presence_schedules` table into it as a `home` pattern +named `Home schedule`, and **drops `presence_schedules`**. Version 0.7.1 +refuses the resulting version-9 database, and could not read +`presence_patterns` anyway, so the only way back to 0.7.1 is a copy of +the database taken **before** the first start on the new version. + +**Before the first start on the new version**, with the old version +still deployed: + +1. Stop writers (as section 8 step 1): + + dc stop api litestream + +2. Copy the database file beside the live one with SQLite's online + backup (includes anything still in the WAL), then check it: + + ts=$(date -u +%Y%m%dT%H%M%SZ) + dc run --rm --no-deps --entrypoint sqlite3 litestream \ + /var/lib/nestquest/nestquest.db \ + ".backup /var/lib/nestquest/pre-v9-$ts.db" + dc run --rm --no-deps --entrypoint sqlite3 litestream \ + /var/lib/nestquest/pre-v9-$ts.db \ + 'PRAGMA integrity_check;' \ + 'SELECT version FROM nestquest_schema_version;' \ + 'SELECT count(*) FROM presence_schedules;' + + Expect `ok`, schema version `8`, and the number of presence + schedules. Write down `$ts`. + +3. Also record the Litestream replica position and the UTC time, so a + point-in-time restore (section 8, `-timestamp`) can reach the + pre-upgrade state even if the local copy is lost: + + dc up -d litestream + dc exec litestream litestream status # note the txid + date -u +%Y-%m-%dT%H:%M:%SZ # note the time + dc stop litestream + + The replica only covers the 7-day retention window (section 7). + +4. Deploy the new version (sections 1–2) and run section 9. Confirm the + live database is at version `9` (the section 8 step 3 `sqlite3` + command against `nestquest.db`, with + `'SELECT version FROM nestquest_schema_version;'`) and that the + Schedule tab lists each child's old schedule as a `Home schedule` + pattern. + +**Rollback to 0.7.1:** check out and build 0.7.1 (section 1), then put +the pre-upgrade database back with section 8: either steps 1 and 4–6 +with `pre-v9-.db` in place of `nestquest.db.restored`, or the full +restore with `-timestamp` set to the time noted in step 3. Presence +pattern changes made after the upgrade are not in that copy and are +lost. Once the new version has proven itself, remove the copy: +`dc run --rm --no-deps --entrypoint rm litestream -f /var/lib/nestquest/pre-v9-.db`. + +This rollback has not yet been rehearsed. diff --git a/design/ADMIN-SPEC.md b/design/ADMIN-SPEC.md index 1cb74a7..17dc1a1 100644 --- a/design/ADMIN-SPEC.md +++ b/design/ADMIN-SPEC.md @@ -37,7 +37,7 @@ One card, rows divided by `--nq-a-divider`, row padding 11px 13px: meta 11px/600 (`3 of 7 · 1 overdue`; all-done in `--nq-a-success`; away reads `Away · returns `) · 82×6px progress track with gradient fill. An away child's avatar is flat `#9898A8` and the row carries a lowercase `override` pill when the absence -comes from a presence override rather than the pattern. +comes from a presence override rather than a presence pattern. ### 2.4 Roomy variant (1h) Header becomes a hero: kicker 11px/700 uppercase, `8 of 12 done` 32px/800, 10px gradient @@ -89,19 +89,54 @@ yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid ## 4. Schedule (1f) -- Header `Schedule` / `2-week cycle · anchor , `. +A child has **zero or more** presence patterns (schema 9). Each pattern has a name, a +kind (`Home` or `Away`), its own cycle length (1–4 weeks), its own anchor date, and a +weekday set per cycle week. The screen shows one child at a time. + +- Header `Schedule` / ` · N patterns` (` · no patterns, home every day` + when the child has none). +- **Child** chip row: one chip per child; the selected chip drives everything below. - **Month grid** card: month label 11px/700 uppercase + chevron pair; weekday initials - 10px/700 `--nq-a-ink-tertiary`; day cells `aspect-ratio: 1`, radius 6px — - rotation-home days `--nq-a-selected` with `#201F7A` numerals, away days - `--nq-a-page` with `--nq-a-ink-secondary`, override days `--nq-a-warning-bg` with - `--nq-a-warning-ink` and a 1px warning border, today filled with the brand gradient in - white 800. Legend row beneath, 10.5px/600, one swatch per state. -- **Presence pattern** card: one row per child, name 13px/700 + rule 12px/600 - (`Weeks 1 & 3 of cycle`, `Every day`). + 10px/700 `--nq-a-ink-tertiary`, **Sunday first** (`S M T W T F S`); day cells + `aspect-ratio: 1`, radius 6px — home days `--nq-a-selected` with `#201F7A` numerals, + away days `--nq-a-page` with `--nq-a-ink-secondary`, override days + `--nq-a-warning-bg` with `--nq-a-warning-ink` and a 1px warning border, today filled + with the brand gradient in white 800. Legend row beneath, 10.5px/600, one swatch per + state (`Home`, `Away`, `Override · away`, `Override · home`, `Today`), then the note + "Combined from every pattern: away beats home; overrides beat both." +- The grid shows the child's **combined** presence, first match wins: an override + covering the date; else away if any Away pattern covers it; else home if any Home + pattern covers it; else away when the child has at least one Home pattern (a Home + pattern lists the days the child is here); else home (only Away patterns, or none). +- **Presence patterns** card: `Add` text action; one row per pattern with + `calendar-check` (Home) / `calendar-x` (Away), title = pattern name 13px/700, meta + `Home · Every 2 weeks` 12px/600, and the weekday set (`Thu, Fri` for a 1-week cycle, + otherwise `Week 1: Sat, Sun · Week 2: No days`). Trailing `pencil` opens the pattern + editor (§4.1); trailing `trash-2` asks `Delete pattern?` inline before deleting. With + no patterns the card reads "No patterns for — home every day." - **Overrides** card: `Add` text action; rows with `calendar-x` / `calendar-check`, title ` away · `, meta reason, trailing `trash-2`. -- Cycle position is always computed from the anchor date. **Never** from ISO week numbers - or week parity (D-004). +- Cycle position is always computed from each pattern's own anchor date. **Never** from + ISO week numbers or week parity (D-004). +- Weekday numbering on the wire stays **Monday=0 .. Sunday=6**; Sunday-first is display + order only. + +### 4.1 Pattern editor + +Pushed screen with a back chevron; title `New pattern` / `Edit pattern`, sub "Repeating +presence for ". Form card fields: + +- **Name** text field (required, not blank). +- **Covered days are** 2-up segmented (`Away` / `Home`), selected = `#18181D`. +- **Cycle length** select (`1 week` … `4 weeks`) and **Anchor date** side by side. + Changing the cycle length keeps existing weeks and adds new weeks covering no day. +- One **weekday picker** per cycle week, labelled `Week N · from ` (the date is + anchor + (N−1) weeks): seven toggle cells in **Sunday-first** order + (`Su Mo Tu We Th Fr Sa`). A week may cover no day. + +Footer: `Cancel` (flex 1, white, bordered) + `Save pattern` (flex 2, gradient), as the +override editor (§6). Save stays disabled until the name is set, the anchor is a valid +date, and something changed. ## 5. History (1g) diff --git a/design/ENTITIES-AND-SERVICES.md b/design/ENTITIES-AND-SERVICES.md index 71fce72..35cb6df 100644 --- a/design/ENTITIES-AND-SERVICES.md +++ b/design/ENTITIES-AND-SERVICES.md @@ -19,6 +19,14 @@ Planned surface from CTXD CONTEXT.MD, adjusted for the design decisions in | `sensor.nestquest_cycle_day` | int | 1d sub-line, 1f header | | *configured* `weather.` | HA core | panel dock | +`present_today` is the child's **combined** presence across all of their presence patterns +(a child has zero or more, each `home` or `away`) and overrides, first match wins: an +override covering the date; else absent if any `away` pattern covers it; else present if +any `home` pattern covers it; else absent when the child has at least one `home` pattern; +else present. `cycle_day` is the 1-based day in the first patterned child's lowest-id +pattern, from that pattern's anchor date (never ISO week numbers or parity); `0` when no +child has a pattern. + Per-instance data is too granular for entity state. The cards read the day's instance list from an attribute payload on the per-child sensor (or a websocket command — implementer's call), shaped: @@ -45,12 +53,17 @@ implementer's call), shaped: | `nestquest.create_quest_definition` | **yes** | 1e `New` | | `nestquest.update_quest_definition` | **yes** | 1e sheet `Save changes` | | `nestquest.set_quest_definition_active` | **yes** | 1e row action | -| `nestquest.set_presence_pattern` | **yes** | 1f presence card | | `nestquest.create_presence_override` | **yes** | 1i `Save override` | | `nestquest.delete_presence_override` | **yes** | 1f override row `trash-2` | | `nestquest.export_history_csv` | **yes** | 1g `CSV` | | `nestquest.manage_child` | **yes** | settings | +Presence patterns have no HA service: the admin surface is the PWA (ARCHITECTURE §10), +which manages a child's patterns through the admin API — +`GET`/`POST /api/v1/admin/children/{child_id}/presence-patterns` to list and add, and +`PATCH`/`DELETE /api/v1/admin/presence-patterns/{pattern_id}` to edit and delete one +pattern (1f patterns card and pattern editor). + `complete_quest` payload from the panel: ```yaml diff --git a/design/IMPLEMENTATION-ORDER.md b/design/IMPLEMENTATION-ORDER.md index 8764254..ce5a2d9 100644 --- a/design/IMPLEMENTATION-ORDER.md +++ b/design/IMPLEMENTATION-ORDER.md @@ -11,7 +11,9 @@ rather than aliasing. - `schema.py`: `task_definitions` → `quest_definitions`, `task_instances` → `quest_instances`. Keep `completion_events`, `children`, `admin_users`, - `schedule_rules`, `presence_schedules`, `presence_overrides`. + `schedule_rules`, `presence_patterns`, `presence_overrides`. (`presence_patterns` — + zero or more `home`/`away` patterns per child — replaced the retired one-per-child + schedule table in schema 9; migration 9 converts its rows into `home` patterns.) - `db.py` / `store.py`: rename DAO methods and any SQL literals. - `const.py`: no table names live there today; keep it that way. - `tests/test_schema.py`, `tests/test_store.py`, `tests/test_db.py`: update expectations. @@ -43,8 +45,9 @@ Nullable int. Set for panel completions, null for admin ones. Still append-only. ## 2. Engines and materialisation Unchanged in intent: pure recurrence engine, pure presence engine with anchor-date -arithmetic, daily materialiser over `horizon_days` at `day_rollover_time`. Only the -instance fan-out changes (assignees × windows). +arithmetic (per pattern, each with its own anchor; since schema 9 it combines a child's +patterns and overrides first-match-wins), daily materialiser over `horizon_days` at +`day_rollover_time`. Only the instance fan-out changes (assignees × windows). ## 3. Services, permissions, entities Implement the service table in `ENTITIES-AND-SERVICES.md` §2 behind the permission diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 054b20e..0f78653 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,6 +61,44 @@ two-writer problem entirely. - **Backups:** continuous replication (e.g. Litestream) plus a proven restore. A configured-but-untested backup is not a backup. +### Presence model (schema 9) + +A child has **zero or more** presence patterns, stored one row each in +`presence_patterns` (`child_id` is not unique): + +| Column | Meaning | +|---|---| +| `name` | Label shown in the admin PWA, not blank | +| `kind` | `home` or `away` — what the pattern claims on the days it covers | +| `cycle_length_weeks` | 1–4; the pattern's own repeating cycle | +| `anchor_date` | ISO date that starts week 1 of the pattern's cycle | +| `pattern` | Pipe-separated weekday sets, one CSV segment per cycle week, weekdays **Monday=0 .. Sunday=6** (e.g. `0,2,4\|1,3`); an empty segment covers no day that week | + +Cycle position is anchor-date arithmetic per pattern — **never** ISO week +numbers or week parity (D-004). A child's presence on a date is decided +first match wins: + +1. A presence override covering the date — its flag is the answer. +2. Any `away` pattern covers the date — **away**. +3. Any `home` pattern covers the date — **present**. +4. The child has at least one `home` pattern — **away** (a home pattern lists + the days the child is home; every other day is away). +5. Otherwise (only `away` patterns, or no patterns) — **present**. + +The core presence engine, the snapshot's `present_today`, the materialiser's +skip-on-away-days rule, and the admin PWA's month grid all apply this order. +The admin plane manages patterns with +`GET`/`POST /api/v1/admin/children/{child_id}/presence-patterns` and +`PATCH`/`DELETE /api/v1/admin/presence-patterns/{pattern_id}`. + +**Migration 9** replaced the older one-schedule-per-child table: it creates +`presence_patterns`, copies every old schedule row into a `home` pattern +named `Home schedule` (cycle length, anchor date and weekday sets +unchanged, row id kept), and drops the old table, all in one transaction. +A lone `home` pattern answers exactly as the old schedule did, so no +child's presence changes. The drop is one-way and 0.7.1 refuses a +version-9 database; see the rollback step in `deploy/RUNBOOK.md` §11. + ## 4. Authentication: two planes The kid panel and the admin PWA have opposite trust levels, so they use -- 2.55.0 From d248b9cb32b102e92143e54b9becca757ddd9e04 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Fri, 25 Sep 2026 07:26:33 -0400 Subject: [PATCH 4/7] Fix: create quest definition materializes its instances (#236) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- .../nestquest/core/quest_definitions.py | 8 +++++ tests/test_api_admin_uncomplete.py | 22 ++++++------ tests/test_api_missed_sweep.py | 9 +++-- tests/test_api_panel.py | 11 ++++-- tests/test_core_sweep.py | 11 ++++-- tests/test_materialize.py | 4 +++ tests/test_quest_definitions.py | 36 +++++++++++++++++++ 7 files changed, 83 insertions(+), 18 deletions(-) diff --git a/custom_components/nestquest/core/quest_definitions.py b/custom_components/nestquest/core/quest_definitions.py index 9918311..8078ce2 100644 --- a/custom_components/nestquest/core/quest_definitions.py +++ b/custom_components/nestquest/core/quest_definitions.py @@ -235,6 +235,11 @@ async def create_quest_definition( transaction, so the returned bundle is a consistent creation snapshot. Raises ValueError on any rejected argument; a rejected or failed create persists nothing. + + After the write, :func:`~.materialize.regenerate_for_definition` + materializes the new definition's instances over the rolling horizon + (host clock, :data:`~.const.DEFAULT_HORIZON_DAYS`), so it reaches the + board without waiting for an unrelated write. """ name = _validate_text(title, "title", required=True) assert name is not None @@ -269,6 +274,9 @@ async def create_quest_definition( ) from error raise decoded_rule = schedule_rule_from_storage(storage) + + await regenerate_for_definition(database, snapshot.definition.id) + return CreatedQuestDefinition( definition=snapshot.definition, rule=decoded_rule, diff --git a/tests/test_api_admin_uncomplete.py b/tests/test_api_admin_uncomplete.py index 23e8211..43fda46 100644 --- a/tests/test_api_admin_uncomplete.py +++ b/tests/test_api_admin_uncomplete.py @@ -514,8 +514,10 @@ async def test_regenerate_household_never_duplicates( client = admin_household.client database = admin_household.database - before = await _instance_count(database) - assert before > 0 # the seed already materialized a few days + # The seed's creates materialized the horizon BEFORE Ada's custody + # pattern existed, so the first household walk may legitimately + # drop her away-day rows; only the second run must be stable. + assert await _instance_count(database) > 0 first = await client.post( "/api/v1/admin/regenerate", json={}, headers=_admin_headers() @@ -525,9 +527,8 @@ async def test_regenerate_household_never_duplicates( assert body["scope"] == "household" assert body["definition_id"] is None assert body["child_id"] is None - assert body["count"] >= before + assert body["count"] > 0 after_first = await _instance_count(database) - assert after_first >= before second = await client.post( "/api/v1/admin/regenerate", json={}, headers=_admin_headers() @@ -544,17 +545,18 @@ async def test_regenerate_definition_scope_rematerializes( ) -> None: """The definition scope rebuilds that definition's horizon, twice, stably. - Creating a definition through the admin route materializes NOTHING - (the core's create path does not regenerate); the scoped regenerate - call is what materializes its instances, and re-running it leaves - the row count exactly where the first run left it. + Creating a definition through the admin route already materializes + its horizon (the core's create path regenerates); the scoped + regenerate call rebuilds the same rows, and re-running it leaves + the row count exactly where the create left it. """ client = admin_api.client database = admin_api.database ada = await _create_child(client, "Ada") definition_id = await _create_daily_definition(client, [ada]) - assert await _instance_count(database) == 0 + created_count = await _instance_count(database) + assert created_count > 0 first = await client.post( "/api/v1/admin/regenerate", @@ -568,7 +570,7 @@ async def test_regenerate_definition_scope_rematerializes( assert body["child_id"] is None assert body["count"] > 0 after_first = await _instance_count(database) - assert after_first == body["count"] + assert after_first == body["count"] == created_count second = await client.post( "/api/v1/admin/regenerate", diff --git a/tests/test_api_missed_sweep.py b/tests/test_api_missed_sweep.py index c86c9a5..95e774c 100644 --- a/tests/test_api_missed_sweep.py +++ b/tests/test_api_missed_sweep.py @@ -151,12 +151,17 @@ async def _seed_missed_household( database, yesterday_iso, yesterday_iso, today=yesterday ) # Today's quest: created after the yesterday walk so it has no - # past-due instance, then materialized for today only. + # past-due instance; ``end_date`` keeps it (and create's horizon + # regeneration) to today only. await _quest_definitions.create_quest_definition( database, "Recoverable chore", _recurrence.ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today_iso} + { + "rule_type": "daily", + "start_date": today_iso, + "end_date": today_iso, + } ), [ada.id], [("afternoon", "17:00")], diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index a37f894..13f9bc3 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -149,13 +149,18 @@ async def _seed_household( (today + datetime.timedelta(days=3)).isoformat(), today=today, ) - # The stale quest is created AFTER the today walk, so its only - # instance is the yesterday one written below. + # The stale quest's rule ends YESTERDAY, so create's own + # regeneration writes nothing and its only instance is the + # yesterday one written below. await _quest_definitions.create_quest_definition( database, "Stale chore", _recurrence.ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": yesterday_iso} + { + "rule_type": "daily", + "start_date": yesterday_iso, + "end_date": yesterday_iso, + } ), [ada.id], [("morning", "09:00")], diff --git a/tests/test_core_sweep.py b/tests/test_core_sweep.py index 4a4215a..944422b 100644 --- a/tests/test_core_sweep.py +++ b/tests/test_core_sweep.py @@ -102,15 +102,20 @@ async def _seed_missed_household( database, "Old chore", rule, [ada.id], [("evening", "18:30")] ) await materialize(database, yesterday_iso, yesterday_iso, today=yesterday) - # A quest due TODAY (created after the yesterday walk so it has - # no past-due instance): the post-downtime catch-up run's + # A quest due TODAY only (created after the yesterday walk so it + # has no past-due instance; ``end_date`` keeps create's horizon + # regeneration to today): the post-downtime catch-up run's # target — inside [watermark=today, later_today) but not # inside the day-one sweep's window. await create_quest_definition( database, "Recoverable chore", ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today_iso} + { + "rule_type": "daily", + "start_date": today_iso, + "end_date": today_iso, + } ), [ada.id], [("afternoon", "17:00")], diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 829040d..436198a 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -468,7 +468,11 @@ async def _materialize_with_change( proceeds with the snapped presence). This deterministically exercises the snapshot-to-insert gap that a config edit can interleave into. + + ``create_quest_definition`` already materializes its horizon, so the + table is cleared first: the gated walk must be the only writer. """ + await database.execute("DELETE FROM quest_instances") original_upsert = QuestInstancesDao.upsert_if_valid entered = asyncio.Event() release = asyncio.Event() diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 6a534c8..23cc27a 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -168,6 +168,42 @@ def test_create_several_children_several_windows_with_due_times( _with_db(tmp_path, "create-many.db")(_body) +def test_create_materializes_future_instances(tmp_path) -> None: + """A fresh create regenerates its rolling horizon immediately. + + Without this the new definition has no ``quest_instances`` rows until + some unrelated write regenerates it, so it never reaches the board. + """ + assert DEFAULT_HORIZON_DAYS >= 7 + + async def _body(database, children, rules, definitions, child): + today = _today_iso() + created = await create_quest_definition( + database, + "Take out bins", + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={0}, + start_date=today, + ), + [child.id], + ["morning"], + ) + rows = await database.fetch_all( + "SELECT child_id, due_date FROM quest_instances " + "WHERE definition_id = ?", + (created.definition.id,), + ) + assert rows, "create materialized no instances" + assert {row[0] for row in rows} == {child.id} + for _child_id, due_date in rows: + assert due_date >= today + assert datetime.date.fromisoformat(due_date).weekday() == 0 + return None + + _with_db(tmp_path, "create-materializes.db")(_body) + + # --------------------------------------------------------------------------- # create_quest_definition: argument rejections # --------------------------------------------------------------------------- -- 2.55.0 From 39e0d715fc07ed54ae6b2c8ec3e49772b2b854ef Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Fri, 25 Sep 2026 07:38:14 -0400 Subject: [PATCH 5/7] Feature: definition sheet start-date field (#238) Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> --- admin/src/definitions/DefinitionsTab.test.tsx | 92 +++++++++++++++++++ admin/src/definitions/DefinitionsTab.tsx | 11 +++ 2 files changed, 103 insertions(+) diff --git a/admin/src/definitions/DefinitionsTab.test.tsx b/admin/src/definitions/DefinitionsTab.test.tsx index b54475f..0db0d5d 100644 --- a/admin/src/definitions/DefinitionsTab.test.tsx +++ b/admin/src/definitions/DefinitionsTab.test.tsx @@ -1118,3 +1118,95 @@ describe("DefinitionsTab icon picker", () => { expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull(); }); }); + +/* ── Start date ─────────────────────────────────────────────────────── */ + +function startDateInput(s: HTMLElement): HTMLInputElement { + return within(s).getByLabelText("Start date", { selector: "input" }) as HTMLInputElement; +} + +describe("DefinitionsTab start date", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("a new task defaults to today and POSTs the chosen start date", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local + await renderReady(); + + fireEvent.click(screen.getByRole("button", { name: "New" })); + const s = sheet(); + const input = startDateInput(s); + expect(input.type).toBe("date"); + expect(input.value).toBe("2026-09-23"); + + fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), { + target: { value: "Feed the cat" }, + }); + fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ })); + fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" })); + fireEvent.click(within(s).getByRole("button", { name: "Evening" })); + fireEvent.change(input, { target: { value: "2026-10-15" } }); + + 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 { rule: DefinitionRule }).rule.start_date).toBe("2026-10-15"); + }); + + it("an edit pre-fills the definition's start date and PATCHes a changed one", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + const input = startDateInput(s); + expect(input.value).toBe("2026-01-01"); + + fireEvent.change(input, { target: { value: "2026-11-02" } }); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect(api.writes()).toEqual([ + expect.objectContaining({ + method: "PATCH", + path: "/api/v1/admin/quest-definitions/10", + body: expect.objectContaining({ rule: rule({ start_date: "2026-11-02" }) }), + }), + ]); + }); + + it("the occurrence preview requests the chosen start date", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + await waitFor(() => expect(api.previews()).toHaveLength(1)); + + fireEvent.change(startDateInput(s), { target: { value: "2027-03-01" } }); + await settleDebounce(); + await waitFor(() => expect(api.previews()).toHaveLength(2)); + expect((api.previews()[1].body as { rule: DefinitionRule }).rule.start_date).toBe( + "2027-03-01", + ); + }); + + it("a cleared start date is not submitted", async () => { + await renderReady(); + fireEvent.click(screen.getByTestId("definition-10")); + const s = sheet(); + fireEvent.change(startDateInput(s), { target: { value: "" } }); + await act(async () => { + fireEvent.click(within(s).getByRole("button", { name: "Save changes" })); + }); + expect(api.writes()).toHaveLength(0); + expect(within(s).getByText("Pick a start date.")).toBeTruthy(); + }); +}); diff --git a/admin/src/definitions/DefinitionsTab.tsx b/admin/src/definitions/DefinitionsTab.tsx index 5db5f16..dcbb698 100644 --- a/admin/src/definitions/DefinitionsTab.tsx +++ b/admin/src/definitions/DefinitionsTab.tsx @@ -177,6 +177,7 @@ function parseWhole(value: string, min: number, max: number): number | null { function buildRule(draft: Draft): DefinitionRule | string { const interval = parseWhole(draft.interval, 1, 999); if (interval === null) return "Interval must be a whole number of at least 1."; + if (draft.startDate === "") return "Pick a start date."; const rule: DefinitionRule = { rule_type: "daily", interval, @@ -740,6 +741,16 @@ function EditSheet({ + + update({ startDate: e.target.value })} + /> + + {orderedWindows.length === 0 ? (

    Pick a window to set its due time.

    -- 2.55.0 From 4f609d7bb90418e80c3767b2c151c09f07ef81bf Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 25 Sep 2026 07:39:18 -0400 Subject: [PATCH 6/7] release: advance version to 0.8.0 and add the 0.8.0 release notes --- RELEASE_NOTES.md | 35 +++++++++++++++++++++++ custom_components/nestquest/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 74861ea..c831ad2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,40 @@ # NestQuest Release Notes +## Version 0.8.0 — 2026-09-25 + +### Scope + +Minor release carrying the multi-pattern presence model (schema 9), the +Sunday-first calendar week, and two admin fixes onto `main`. + +- **Multiple presence patterns per child (schema 9):** a child now has zero or + more presence patterns instead of at most one. Each pattern has a name, a kind + (`home` or `away`), its own cycle length (1–4 weeks), its own anchor date and a + per-week weekday set. Evaluation is first-match-wins — overrides, then away, + then home, then absent when the child has at least one home pattern, else + present. Migration 9 copies each existing schedule into a `home` pattern named + "Home schedule" and drops the old `presence_schedules` table. (PRs #231, #232.) +- **Sunday-first calendar week:** the admin Schedule month grid and the + Definitions weekday picker now render weeks Sunday-first; the stored/API + weekday numbering is unchanged (Monday=0..Sunday=6). (PR #230.) +- **Fix — newly created tasks materialize immediately:** creating a quest + definition now regenerates its future instances over the rolling horizon, so a + new task reaches the panel without waiting for an unrelated write. (PRs #235, + #236.) +- **Definition start date:** the definition edit sheet gains a Start date field + so a task can be scheduled to begin on a future date. (PRs #237, #238.) +- **Docs:** the multi-pattern decision (D-016), the migration-9 rollback step in + the runbook, and the ADMIN-SPEC Schedule section. (PRs #233, #234.) + +### Upgrade note + +This release runs schema version **9**. Version 0.7.1 refuses a version-9 +database, so take a copy of the SQLite file (or note the Litestream generation) +before the first start on 0.8.0 if you need a rollback path. + +### Requirements + + ## Version 0.7.1 — 2026-09-24 ### Scope diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index d8a94a4..d9157c9 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues", "requirements": [], - "version": "0.7.1" + "version": "0.8.0" } diff --git a/pyproject.toml b/pyproject.toml index 11e27f9..e68ac12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.7.1" +version = "0.8.0" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 1f99994..ff0653f 100644 --- a/uv.lock +++ b/uv.lock @@ -373,7 +373,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.7.1" +version = "0.8.0" source = { virtual = "." } [package.dev-dependencies] -- 2.55.0 From 2906da7ed11b8a068c5925cbada15d62303af8f6 Mon Sep 17 00:00:00 2001 From: Joshua Date: Fri, 25 Sep 2026 07:42:04 -0400 Subject: [PATCH 7/7] release: clarify the 0.8.0 scope wording --- RELEASE_NOTES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c831ad2..2fdcea9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -5,7 +5,8 @@ ### Scope Minor release carrying the multi-pattern presence model (schema 9), the -Sunday-first calendar week, and two admin fixes onto `main`. +Sunday-first calendar week, and the create-materializes fix, plus the +definition start-date field, onto `main`. - **Multiple presence patterns per child (schema 9):** a child now has zero or more presence patterns instead of at most one. Each pattern has a name, a kind -- 2.55.0