diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 299e4c0..6891697 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import "./App.css"; import DefinitionsTab from "./definitions/DefinitionsTab"; +import ScheduleTab from "./schedule/ScheduleTab"; import TodayTab from "./today/TodayTab"; const TABS = [ @@ -16,7 +17,7 @@ export default function App() { const [active, setActive] = useState("today"); // Built screens own their header (ADMIN-SPEC §1); the generic shell header // stays for placeholder tabs and is visually hidden otherwise. - const hasScreen = active === "today" || active === "tasks"; + const hasScreen = active === "today" || active === "tasks" || active === "schedule"; return (
@@ -33,6 +34,8 @@ export default function App() { ) : active === "tasks" ? ( + ) : active === "schedule" ? ( + ) : (

{TABS.find((tab) => tab.id === active)?.label} placeholder — screens diff --git a/admin/src/api/definitions.ts b/admin/src/api/definitions.ts index d17582b..b38f83b 100644 --- a/admin/src/api/definitions.ts +++ b/admin/src/api/definitions.ts @@ -103,7 +103,7 @@ function readDetail(body: unknown): string | null { return null; } -async function readJson(response: Response): Promise { +export async function readJson(response: Response): Promise { if (!response.ok) { let body: unknown = null; try { diff --git a/admin/src/api/presence.ts b/admin/src/api/presence.ts new file mode 100644 index 0000000..2789198 --- /dev/null +++ b/admin/src/api/presence.ts @@ -0,0 +1,82 @@ +/** + * Typed client for the admin presence routes: a child's repeating schedule + * (/api/v1/admin/children/{id}/presence-schedule) and the household's + * date-range overrides (/api/v1/admin/presence-overrides). + */ +import { apiFetch } from "./client"; +import { CHILDREN_PATH, readJson } from "./definitions"; + +/** + * Cycle week index ("0".."n-1", JSON object keys) -> present weekdays + * (0 = Monday .. 6 = Sunday). An empty list is away that whole week. + */ +export type PresencePattern = Record; + +export interface PresenceScheduleBody { + cycle_length_weeks: number; + /** "YYYY-MM-DD"; pins cycle week 0. */ + anchor_date: string; + pattern: PresencePattern; +} + +export interface PresenceSchedule extends PresenceScheduleBody { + child_id: number; +} + +export interface PresenceOverride { + id: number; + child_id: number; + start_date: string; + end_date: string; + is_present: boolean; + note: string | null; +} + +export interface PresenceOverrideFilter { + childId?: number; + /** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */ + start?: string; + end?: string; +} + +export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides"; + +export function presenceSchedulePath(childId: number): string { + return `${CHILDREN_PATH}/${childId}/presence-schedule`; +} + +/** The child's schedule, or null when it has none (present every day). */ +export async function fetchPresenceSchedule(childId: number): Promise { + const body = await readJson<{ schedule: PresenceSchedule | null }>( + await apiFetch(presenceSchedulePath(childId)), + ); + return body.schedule; +} + +/** Set (upsert) the child's schedule; resolves to the schedule as stored. */ +export async function savePresenceSchedule( + childId: number, + body: PresenceScheduleBody, +): Promise { + return readJson( + await apiFetch(presenceSchedulePath(childId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export async function fetchPresenceOverrides( + filter: PresenceOverrideFilter = {}, +): Promise { + const params = new URLSearchParams(); + if (filter.childId !== undefined) params.set("child_id", String(filter.childId)); + if (filter.start !== undefined) params.set("start", filter.start); + if (filter.end !== undefined) params.set("end", filter.end); + const query = params.toString(); + const body = await readJson<{ overrides: PresenceOverride[] }>( + await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH), + ); + return body.overrides; +} diff --git a/admin/src/index.css b/admin/src/index.css index f725495..bfdc5d7 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -23,6 +23,9 @@ --nq-a-danger: #dc2626; --nq-a-danger-strong: #b91c1c; --nq-a-danger-bg: #fee2e2; + --nq-a-warning-ink: #92400e; + --nq-a-warning-bg: #fef3c7; + --nq-a-warning-border: rgba(217, 119, 6, 0.35); --nq-a-info-bg: #dbeafe; --nq-a-info-ink: #1e3a8a; --nq-a-info-icon: #1d4ed8; diff --git a/admin/src/schedule/ScheduleTab.css b/admin/src/schedule/ScheduleTab.css new file mode 100644 index 0000000..34aa999 --- /dev/null +++ b/admin/src/schedule/ScheduleTab.css @@ -0,0 +1,379 @@ +/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at + the 390x844 reference; on wider viewports the column is capped and centred. */ + +.schedule-screen { + width: 100%; + max-width: 640px; + margin: 0 auto; +} + +.schedule-header { + background: var(--nq-a-surface); + border-bottom: 1px solid var(--nq-a-border); + padding: 14px 16px; +} + +.schedule-title { + margin: 0; + font-size: var(--nq-a-size-screen); + font-weight: 800; + letter-spacing: -0.02em; +} + +.schedule-sub { + margin: 2px 0 0; + min-height: 1.4em; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */ +.schedule-scroll { + display: flex; + flex-direction: column; + gap: var(--nq-a-density-gap); + padding: 14px 16px; +} + +.schedule-card { + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); +} + +/* Child selector */ +.schedule-children { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.schedule-child-chip { + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-radius-pill); + background: var(--nq-a-surface); + color: var(--nq-a-ink-secondary); + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + +.schedule-child-chip[aria-pressed="true"] { + border-color: var(--nq-brand-blue); + background: var(--nq-a-selected); + color: var(--nq-brand-blue); +} + +/* Month grid card */ +.schedule-month { + padding: var(--nq-a-density-card-pad); +} + +.schedule-month-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.schedule-month-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.schedule-chevrons { + display: flex; + gap: 4px; +} + +.schedule-chevron { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid var(--nq-a-border); + border-radius: 6px; + background: var(--nq-a-surface); + color: var(--nq-a-ink-secondary); + cursor: pointer; +} + +.schedule-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.schedule-weekday { + text-align: center; + font-size: 10px; + font-weight: 700; + color: var(--nq-a-ink-tertiary); +} + +.schedule-day { + aspect-ratio: 1; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.schedule-day--home { + background: var(--nq-a-selected); + color: #201f7a; +} + +.schedule-day--away { + background: var(--nq-a-page); + color: var(--nq-a-ink-secondary); +} + +.schedule-day--override-away, +.schedule-day--override-home { + background: var(--nq-a-warning-bg); + color: var(--nq-a-warning-ink); + border-color: var(--nq-a-warning-border); +} + +/* An override that keeps the child home reads with a dashed border. */ +.schedule-day--override-home { + border-style: dashed; +} + +.schedule-day--today { + background: var(--nq-brand-gradient); + border-color: transparent; + color: #fff; + font-weight: 800; +} + +.schedule-legend { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} + +.schedule-legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 10.5px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.schedule-swatch { + display: inline-block; + width: 12px; + height: 12px; + border: 1px solid transparent; + border-radius: 3px; +} + +/* Pattern editing controls */ +.schedule-controls { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--nq-a-divider); +} + +.schedule-field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: var(--nq-a-size-label); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--nq-a-ink-secondary); +} + +.schedule-field input, +.schedule-field select { + min-height: 36px; + padding: 0 8px; + border: 1px solid var(--nq-a-border); + border-radius: 6px; + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13px; + font-weight: 600; + text-transform: none; + letter-spacing: normal; +} + +.schedule-save { + min-height: 36px; + margin-left: auto; + padding: 0 14px; + border: 0; + border-radius: var(--nq-a-density-radius); + background: var(--nq-brand-gradient); + color: #fff; + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.schedule-save:disabled { + opacity: 0.45; + cursor: default; +} + +.schedule-save-status { + margin: 8px 0 0; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-success); +} + +.schedule-save-status--error { + color: var(--nq-a-danger-strong); +} + +/* Presence pattern + overrides cards */ +.schedule-section-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.schedule-section-label { + margin: 6px 0 -2px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--nq-a-ink-secondary); +} + +.schedule-text-action { + padding: 4px 0 0; + border: 0; + background: none; + color: var(--nq-brand-blue); + font-family: inherit; + font-size: 12.5px; + font-weight: 700; + cursor: pointer; +} + +.schedule-list { + margin: 0; + padding: 0; + list-style: none; +} + +.schedule-list > li + li { + border-top: 1px solid var(--nq-a-divider); +} + +.schedule-row { + display: flex; + align-items: center; + gap: 10px; + min-height: var(--nq-a-tap-min); + padding: var(--nq-a-density-row-pad); +} + +.schedule-row-glyph { + flex: none; + display: flex; + color: var(--nq-a-warning-ink); +} + +.schedule-row-text { + flex: 1; + min-width: 0; +} + +.schedule-row-title { + font-size: 13px; + font-weight: 700; +} + +.schedule-row-meta { + font-size: var(--nq-a-size-meta); + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.schedule-rule { + flex: none; + font-size: 12px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.schedule-icon-action { + flex: none; + display: grid; + place-items: center; + width: var(--nq-a-tap-min); + height: var(--nq-a-tap-min); + margin: -8px -10px -8px 0; + padding: 0; + border: 0; + background: none; + color: var(--nq-a-ink-tertiary); + cursor: pointer; +} + +.schedule-empty { + padding: var(--nq-a-density-card-pad); + font-size: 13px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +/* Loading / error */ +.schedule-status { + padding: 24px 13px; + background: var(--nq-a-surface); + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + text-align: center; + font-size: 13.5px; + font-weight: 600; + color: var(--nq-a-ink-secondary); +} + +.schedule-status--error { + color: var(--nq-a-danger-strong); +} + +.schedule-status--error p { + margin: 0 0 10px; +} + +.schedule-retry { + padding: 8px 16px; + border: 1px solid var(--nq-a-border); + border-radius: var(--nq-a-density-radius); + background: var(--nq-a-surface); + color: var(--nq-a-ink); + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} diff --git a/admin/src/schedule/ScheduleTab.test.tsx b/admin/src/schedule/ScheduleTab.test.tsx new file mode 100644 index 0000000..feae62c --- /dev/null +++ b/admin/src/schedule/ScheduleTab.test.tsx @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import ScheduleTab from "./ScheduleTab"; +import type { AdminChild } from "../api/definitions"; +import type { PresenceOverride, PresenceSchedule } from "../api/presence"; +import { storeTokens } from "../auth/oidc"; + +function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild { + return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active }; +} + +const CHILDREN = [ + child(3, "Rory", 3), + child(1, "Declan", 1), + child(2, "Maeve", 2), + child(9, "Former", 4, false), +]; + +// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun. +// Keys arrive as JSON strings, exactly as the API serializes them. +const DECLAN: PresenceSchedule = { + child_id: 1, + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] }, +}; + +const MAEVE: PresenceSchedule = { + child_id: 2, + cycle_length_weeks: 4, + anchor_date: "2026-09-07", + pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] }, +}; + +const OVERRIDES: PresenceOverride[] = [ + { id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" }, + { id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null }, + { id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" }, +]; + +const TODAY = "2026-09-23"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +let fetchMock: ReturnType; +let putResponse: (body: unknown) => Response; + +function routeFetch(url: string, init: RequestInit = {}): Response { + const path = new URL(url, "http://x").pathname; + if (init.method === "PUT") return putResponse(JSON.parse(String(init.body))); + if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN }); + const match = /\/children\/(\d+)\/presence-schedule$/.exec(path); + if (match) { + const id = Number(match[1]); + return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null }); + } + if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES }); + return jsonResponse({ detail: "unexpected" }, 500); +} + +function day(date: string): HTMLElement { + const cell = document.querySelector(`[data-date="${date}"]`); + if (!cell) throw new Error(`no cell for ${date}`); + return cell; +} + +async function renderReady() { + render(); + await screen.findByTestId("pattern-1"); +} + +describe("ScheduleTab", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) }); + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("loads active children, their schedules and the overrides", async () => { + await renderReady(); + const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname); + expect(urls).toContain("/api/v1/admin/children"); + expect(urls).toContain("/api/v1/admin/children/1/presence-schedule"); + expect(urls).toContain("/api/v1/admin/children/2/presence-schedule"); + expect(urls).toContain("/api/v1/admin/children/3/presence-schedule"); + expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule"); + expect(urls).toContain("/api/v1/admin/presence-overrides"); + const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button"); + expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]); + expect(chips[0].getAttribute("aria-pressed")).toBe("true"); + }); + + it("header shows the cycle length and anchor", async () => { + await renderReady(); + expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy(); + expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy(); + }); + + it("month grid renders home, away, override and today states from the anchor", async () => { + await renderReady(); + expect(screen.getByTestId("month-label").textContent).toBe("September 2026"); + const states: Record = { + "2026-09-01": "away", // before the anchor: cycle week 1, Tuesday + "2026-09-05": "home", // before the anchor: cycle week 1, Saturday + "2026-09-07": "home", // anchor: week 0 + "2026-09-11": "override-away", + "2026-09-15": "away", // week 1, Tuesday + "2026-09-16": "override-home", + "2026-09-19": "home", // week 1, Saturday + "2026-09-22": "home", // wrap back to week 0 + "2026-09-29": "away", // week 1 again + }; + for (const [date, state] of Object.entries(states)) { + expect(day(date).dataset.state, date).toBe(state); + expect(day(date).className).toContain(`schedule-day--${state}`); + expect(day(date).className).not.toContain("schedule-day--today"); + } + const today = day(TODAY); + expect(today.className).toContain("schedule-day--today"); + expect(today.dataset.state).toBe("home"); + expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home"); + // September 2026 starts on a Tuesday: one leading pad after the initials. + const grid = screen.getByRole("group", { name: "Month grid" }); + expect(grid.children[7].tagName).toBe("SPAN"); + expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01"); + + const legend = screen.getByRole("list", { name: "Legend" }); + expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([ + "Home", + "Away", + "Override · away", + "Override · home", + "Today", + ]); + }); + + it("navigates months with the chevrons", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Next month" })); + expect(screen.getByTestId("month-label").textContent).toBe("October 2026"); + // Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away. + expect(day("2026-10-01").dataset.state).toBe("away"); + expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday + expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again + fireEvent.click(screen.getByRole("button", { name: "Previous month" })); + expect(screen.getByTestId("month-label").textContent).toBe("September 2026"); + }); + + it("presence pattern card shows a rule per child", async () => { + await renderReady(); + expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy(); + expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy(); + expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy(); + }); + + it("overrides card lists the selected child's overrides with inert actions", async () => { + await renderReady(); + const list = screen.getByRole("list", { name: "Overrides" }); + expect(within(list).getAllByRole("listitem")).toHaveLength(2); + const away = within(list).getByTestId("override-41"); + expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy(); + expect(within(away).getByText("Dentist trip")).toBeTruthy(); + expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy(); + expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Add" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Maeve" })); + expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy(); + expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy(); + }); + + it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => { + await renderReady(); + fireEvent.click(screen.getByRole("button", { name: "Rory" })); + expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy(); + expect(day("2026-09-15").dataset.state).toBe("home"); + expect(screen.getByText("No overrides for Rory.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe( + true, + ); + }); + + it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => { + await renderReady(); + expect(day("2026-09-01").dataset.state).toBe("away"); + fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday + + // The draft already shows the edit, and every other week-1 Tuesday follows. + expect(day("2026-09-01").dataset.state).toBe("home"); + expect(day("2026-09-15").dataset.state).toBe("home"); + expect(day("2026-09-08").dataset.state).toBe("home"); + + fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); + expect(await screen.findByText("Pattern saved.")).toBeTruthy(); + + const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT"); + expect(put).toBeTruthy(); + const [url, init] = put!; + expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule"); + expect(init.headers.Authorization).toBe("Bearer at-123"); + expect(JSON.parse(init.body)).toEqual({ + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] }, + }); + + expect(day("2026-09-15").dataset.state).toBe("home"); + expect(day("2026-09-29").dataset.state).toBe("home"); + expect(day("2026-09-30").dataset.state).toBe("away"); + expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe( + true, + ); + }); + + it("saves an edited anchor date and cycle length", async () => { + await renderReady(); + fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } }); + fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } }); + expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy(); + // Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added). + fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); + await screen.findByText("Pattern saved."); + const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!; + expect(JSON.parse(init.body)).toEqual({ + cycle_length_weeks: 3, + anchor_date: "2026-09-14", + pattern: { + "0": [0, 1, 2, 3, 4, 5, 6], + "1": [5, 6], + "2": [0, 1, 2, 3, 4, 5, 6], + }, + }); + }); + + it("a 422 shows the API detail and keeps the draft", async () => { + putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422); + await renderReady(); + fireEvent.click(day("2026-09-01")); + fireEvent.click(screen.getByRole("button", { name: "Save pattern" })); + + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toBe("anchor_date must be a Monday"); + expect(day("2026-09-01").dataset.state).toBe("home"); + expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe( + false, + ); + // The saved rule is unchanged. + expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy(); + }); + + it("scroll column ends with 92px bottom padding", async () => { + await renderReady(); + expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px"); + }); + + it("shows an error state on a failed load and retries", async () => { + fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 })); + render(); + const error = await screen.findByTestId("schedule-error"); + expect(error.textContent).toContain("could not be loaded"); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByTestId("pattern-1")).toBeTruthy(); + }); +}); diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx new file mode 100644 index 0000000..1a76862 --- /dev/null +++ b/admin/src/schedule/ScheduleTab.tsx @@ -0,0 +1,440 @@ +/** + * Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4). + * + * Every day's cycle position comes from the anchor date (./cycle.ts), never + * from ISO week numbers or week parity (D-004). + */ +import { useEffect, useState } from "react"; +import { ApiForbiddenError } from "../api/client"; +import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; +import { + fetchPresenceOverrides, + fetchPresenceSchedule, + savePresenceSchedule, + type PresenceOverride, + type PresenceSchedule, + type PresenceScheduleBody, +} from "../api/presence"; +import { + cycleWeekIndex, + dayState, + fullPattern, + localTodayIso, + monthDates, + monthStart, + patternIncludes, + presenceRule, + resizePattern, + togglePatternDay, + weekdayOf, + type DayState, +} from "./cycle"; +import { anchorLabel, dateRange, monthLabel, shortDate } from "./format"; +import { + CalendarCheckGlyph, + CalendarXGlyph, + ChevronLeftGlyph, + ChevronRightGlyph, + Trash2Glyph, +} from "./glyphs"; +import "./ScheduleTab.css"; + +/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ +export const SCROLL_BOTTOM_PADDING = "92px"; + +export const DEFAULT_CYCLE_LENGTH_WEEKS = 2; +const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4]; +const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"]; + +export const STATE_LABELS: Record = { + home: "Home", + away: "Away", + "override-away": "Override · away", + "override-home": "Override · home", +}; + +interface Loaded { + children: AdminChild[]; + schedules: Record; + overrides: PresenceOverride[]; +} + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; data: Loaded }; + +type SaveState = + | { kind: "idle" } + | { kind: "saving" } + | { kind: "saved" } + | { kind: "error"; message: string }; + +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))), + fetchPresenceOverrides(), + ]); + return { + children, + schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])), + overrides, + }; +} + +function saveErrorMessage(error: unknown): string { + if (error instanceof ApiForbiddenError) return error.message; + if (error instanceof ApiRequestError && error.detail) return error.detail; + return "The pattern could not be saved. Check your connection and try again."; +} + +function ScreenHeader({ sub }: { sub: string }) { + return ( +

+

Schedule

+

{sub}

+
+ ); +} + +interface MonthGridProps { + month: string; + today: string; + schedule: PresenceScheduleBody; + /** Null when the child has no saved schedule and no edits: home every day. */ + effective: PresenceScheduleBody | null; + overrides: PresenceOverride[]; + onMonth: (delta: number) => void; + onToggle: (date: string) => void; +} + +function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) { + const dates = monthDates(month); + const leading = weekdayOf(month); + return ( + <> +
+ + {monthLabel(month)} + +
+ + +
+
+
+ {WEEKDAY_INITIALS.map((initial, i) => ( + + ))} + {Array.from({ length: leading }, (_, i) => ( +
+
    + {(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => ( +
  • +
  • + ))} +
  • +
  • +
+ + ); +} + +function OverrideRow({ override, childName }: { override: PresenceOverride; childName: string }) { + return ( +
  • + + {override.is_present ? : } + +
    +
    + {childName} {override.is_present ? "home" : "away"} ·{" "} + {dateRange(override.start_date, override.end_date)} +
    +
    {override.note || "No reason given"}
    +
    + {/* TODO(4ae0c879): the override editor wires delete; inert until then. */} + +
  • + ); +} + +export interface ScheduleTabProps { + /** "YYYY-MM-DD"; defaults to the viewer's local date. */ + today?: string; +} + +export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) { + const [state, setState] = useState({ kind: "loading" }); + 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" }); + + useEffect(() => { + let cancelled = false; + setState({ kind: "loading" }); + loadSchedule() + .then((data) => { + if (cancelled) return; + setState({ kind: "ready", data }); + setSelectedId((current) => current ?? data.children[0]?.id ?? null); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof ApiForbiddenError + ? error.message + : "The schedule could not be loaded. Check your connection and try again."; + setState({ kind: "error", message }); + }); + return () => { + cancelled = true; + }; + }, [attempt]); + + let sub = ""; + let body; + if (state.kind === "loading") { + body = ( +
    + Loading schedule… +
    + ); + } else if (state.kind === "error") { + body = ( +
    +

    {state.message}

    + +
    + ); + } else if (state.data.children.length === 0) { + body =
    No children yet.
    ; + } else { + const { data } = state; + const child = data.children.find((c) => c.id === selectedId) ?? data.children[0]; + const saved = data.schedules[child.id] ?? null; + const draft = drafts[child.id]; + // Edits start from the saved schedule, or — with none — from "home every + // day" on a 2-week cycle anchored at the displayed month's 1st. + const schedule: PresenceScheduleBody = draft ?? + saved ?? { + cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS, + anchor_date: month, + pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS), + }; + const effective = draft ?? saved; + const childOverrides = data.overrides.filter((o) => o.child_id === child.id); + sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`; + + const edit = (next: PresenceScheduleBody) => { + setDrafts((all) => ({ ...all, [child.id]: next })); + setSave({ kind: "idle" }); + }; + const toggle = (date: string) => { + const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks); + edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) }); + }; + const submit = () => { + if (!draft) return; + const childId = child.id; + setSave({ kind: "saving" }); + const { cycle_length_weeks, anchor_date, pattern } = draft; + savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern }) + .then((stored) => { + setState((current) => + current.kind === "ready" + ? { + kind: "ready", + data: { + ...current.data, + schedules: { ...current.data.schedules, [childId]: stored }, + }, + } + : current, + ); + setDrafts((all) => { + const rest = { ...all }; + delete rest[childId]; + return rest; + }); + setSave({ kind: "saved" }); + }) + .catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) })); + }; + + body = ( + <> +
    + {data.children.map((c) => ( + + ))} +
    + +
    + 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)} +
    • + ))} +
    + +
    +

    Overrides

    + {/* TODO(4ae0c879): the override editor wires Add; inert until then. */} + +
    + {childOverrides.length > 0 ? ( +
      + {childOverrides.map((override) => ( + + ))} +
    + ) : ( +
    No overrides for {child.display_name}.
    + )} + + ); + } + + return ( +
    + +
    + {body} +
    +
    + ); +} diff --git a/admin/src/schedule/cycle.test.ts b/admin/src/schedule/cycle.test.ts new file mode 100644 index 0000000..26217f5 --- /dev/null +++ b/admin/src/schedule/cycle.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + cycleWeekIndex, + dayState, + monthDates, + monthStart, + presenceRule, + resizePattern, + togglePatternDay, + weekdayOf, +} from "./cycle"; +import type { PresenceOverride, PresenceScheduleBody } from "../api/presence"; + +// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only. +const SCHEDULE: PresenceScheduleBody = { + cycle_length_weeks: 2, + anchor_date: "2026-09-07", + pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] }, +}; + +describe("cycleWeekIndex", () => { + it("follows the anchor across a month, wrapping every 2 weeks", () => { + const expected: Record = {}; + for (const date of monthDates("2026-09-01")) { + const day = Number(date.slice(8)); + // Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1; + // Sep 1-6 lie before the anchor and walk back into week 1. + expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2; + } + for (const [date, week] of Object.entries(expected)) { + expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week); + } + expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0); + expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1); + }); + + it("walks the cycle backwards before the anchor (floor division)", () => { + expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1 + expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7 + expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8 + expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14 + expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15 + expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle + }); + + it("ignores ISO week parity across a 53-week year", () => { + // 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so + // parity would call them the same week. From the anchor they alternate. + expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0); + expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1); + }); +}); + +describe("calendar helpers", () => { + it("weekdayOf is Monday=0", () => { + expect(weekdayOf("2026-09-07")).toBe(0); + expect(weekdayOf("2026-09-01")).toBe(1); + expect(weekdayOf("2026-09-06")).toBe(6); + }); + + it("monthStart shifts across year ends", () => { + expect(monthStart("2026-12-15", 1)).toBe("2027-01-01"); + expect(monthStart("2026-01-31", -1)).toBe("2025-12-01"); + expect(monthDates("2026-02-01")).toHaveLength(28); + }); +}); + +describe("dayState", () => { + const overrides: PresenceOverride[] = [ + { id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null }, + { id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null }, + ]; + + it("applies overrides first, then the anchor-based pattern", () => { + expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home"); + expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away"); + expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away"); + expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home"); + expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home"); + expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away"); + expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home"); + }); + + it("treats a child with no schedule as home every day", () => { + expect(dayState(null, [], "2026-09-15")).toBe("home"); + }); +}); + +describe("pattern editing and rule text", () => { + it("toggles one weekday of one cycle week", () => { + expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({ + 0: [0, 1, 2, 3, 4, 5, 6], + 1: [1, 5, 6], + }); + expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]); + }); + + it("resizes the pattern to the cycle length", () => { + expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] }); + expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); + + it("describes the presence rule in plain language", () => { + expect(presenceRule(null)).toBe("Every day"); + expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe( + "Every day", + ); + expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle"); + expect( + presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }), + ).toBe("Weeks 1 & 3 of cycle"); + expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle"); + expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home"); + }); +}); diff --git a/admin/src/schedule/cycle.ts b/admin/src/schedule/cycle.ts new file mode 100644 index 0000000..ac16e6a --- /dev/null +++ b/admin/src/schedule/cycle.ts @@ -0,0 +1,142 @@ +/** + * Pure presence-cycle math for the Schedule tab. + * + * Cycle position comes from the anchor date ONLY, mirroring + * core/presence.py `cycle_week_index`: + * `((date - anchor).days // 7) % cycle_length_weeks` with floor division, + * so dates before the anchor walk the cycle backwards. Never ISO week + * numbers or week parity (D-004). + * + * Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no + * viewer offset or DST change can shift a day. + */ +import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence"; + +const MS_PER_DAY = 86_400_000; + +export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + +/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */ +export function dayNumber(iso: string): number { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (!match) throw new Error(`Invalid calendar date: ${iso}`); + const [, year, month, day] = match; + return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY; +} + +export function isoFromDayNumber(days: number): string { + return new Date(days * MS_PER_DAY).toISOString().slice(0, 10); +} + +/** Floor modulo: always in 0..divisor-1, like Python's `%`. */ +function mod(value: number, divisor: number): number { + return ((value % divisor) + divisor) % divisor; +} + +/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */ +export function weekdayOf(iso: string): number { + return mod(dayNumber(iso) + 3, 7); +} + +/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */ +export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number { + const days = dayNumber(date) - dayNumber(anchorDate); + return mod(Math.floor(days / 7), cycleLengthWeeks); +} + +/** The viewer's local calendar date. */ +export function localTodayIso(now: Date = new Date()): string { + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${now.getFullYear()}-${month}-${day}`; +} + +/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */ +export function monthStart(iso: string, delta = 0): string { + const [year, month] = iso.split("-").map(Number); + const date = new Date(Date.UTC(year, month - 1 + delta, 1)); + return date.toISOString().slice(0, 10); +} + +/** Every date of the month starting at `firstIso`. */ +export function monthDates(firstIso: string): string[] { + const start = dayNumber(firstIso); + const end = dayNumber(monthStart(firstIso, 1)); + const dates: string[] = []; + for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day)); + return dates; +} + +/** Present every day for every week of the cycle (the no-schedule case). */ +export function fullPattern(cycleLengthWeeks: number): PresencePattern { + const pattern: PresencePattern = {}; + for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS]; + return pattern; +} + +export type DayState = "home" | "away" | "override-home" | "override-away"; + +export function patternIncludes( + schedule: PresenceScheduleBody, + date: string, +): boolean { + const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks); + return (schedule.pattern[week] ?? []).includes(weekdayOf(date)); +} + +/** + * A date's state for one child: an override covering the date wins; + * otherwise the schedule's pattern decides (no schedule = home every day). + */ +export function dayState( + schedule: PresenceScheduleBody | null, + overrides: PresenceOverride[], + date: string, +): DayState { + const override = overrides.find((o) => o.start_date <= date && date <= o.end_date); + if (override) return override.is_present ? "override-home" : "override-away"; + if (!schedule) return "home"; + return patternIncludes(schedule, date) ? "home" : "away"; +} + +/** Flip `weekday` in `week` of the pattern; returns a new pattern. */ +export function togglePatternDay( + pattern: PresencePattern, + week: number, + weekday: number, +): PresencePattern { + const days = pattern[week] ?? []; + const next = days.includes(weekday) + ? days.filter((d) => d !== weekday) + : [...days, weekday].sort((a, b) => a - b); + return { ...pattern, [week]: next }; +} + +/** Fit the pattern to a new cycle length; added weeks start present every day. */ +export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern { + const next: PresencePattern = {}; + for (let week = 0; week < cycleLengthWeeks; week += 1) { + next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS]; + } + return next; +} + +function joinWeeks(weeks: number[]): string { + if (weeks.length === 1) return String(weeks[0]); + return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`; +} + +/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */ +export function presenceRule(schedule: PresenceScheduleBody | null): string { + if (!schedule) return "Every day"; + const weeks: number[] = []; + let full = true; + for (let week = 0; week < schedule.cycle_length_weeks; week += 1) { + const days = schedule.pattern[week] ?? []; + if (days.length > 0) weeks.push(week + 1); + if (days.length < 7) full = false; + } + if (full) return "Every day"; + if (weeks.length === 0) return "Never home"; + return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`; +} diff --git a/admin/src/schedule/format.ts b/admin/src/schedule/format.ts new file mode 100644 index 0000000..357964a --- /dev/null +++ b/admin/src/schedule/format.ts @@ -0,0 +1,37 @@ +/** + * Date labels for the Schedule tab. Calendar dates are formatted in UTC from + * their components, so the printed day never shifts with the viewer's offset. + */ +import { dayNumber } from "./cycle"; + +const LOCALE = "en-US"; + +function utcDate(iso: string): Date { + return new Date(dayNumber(iso) * 86_400_000); +} + +function format(iso: string, options: Intl.DateTimeFormatOptions): string { + return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso)); +} + +/** "Tuesday, Sep 1" */ +export function anchorLabel(iso: string): string { + return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`; +} + +/** "September 2026" */ +export function monthLabel(iso: string): string { + return format(iso, { month: "long", year: "numeric" }); +} + +/** "Sep 4" */ +export function shortDate(iso: string): string { + return format(iso, { month: "short", day: "numeric" }); +} + +/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */ +export function dateRange(start: string, end: string): string { + if (start === end) return shortDate(start); + const sameMonth = start.slice(0, 7) === end.slice(0, 7); + return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`; +} diff --git a/admin/src/schedule/glyphs.tsx b/admin/src/schedule/glyphs.tsx new file mode 100644 index 0000000..d6163d4 --- /dev/null +++ b/admin/src/schedule/glyphs.tsx @@ -0,0 +1,76 @@ +/** + * Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid), + * CDN-free like the Today and Tasks tabs' glyphs. + */ +import type { ReactNode } from "react"; + +function Glyph({ size, children }: { size: number; children: ReactNode }) { + return ( + + ); +} + +export function ChevronLeftGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +export function ChevronRightGlyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +} + +function CalendarFrame() { + return ( + <> + + + + + ); +} + +export function CalendarXGlyph({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function CalendarCheckGlyph({ size = 18 }: { size?: number }) { + return ( + + + + + ); +} + +export function Trash2Glyph({ size = 16 }: { size?: number }) { + return ( + + + + ); +}