Move the Admin PWA Schedule tab to multi-pattern presence
The admin client still called the removed one-per-child /presence-schedule route, so the Schedule tab could not load or save (review finding F1 on PR #231). The client now uses the presence-pattern routes: list and create under /children/{id}/presence-patterns, and PATCH / DELETE on /presence-patterns/{id}. The Schedule screen lists every pattern of the selected child (name, Home/Away, cycle length, weekday set) with Add, Edit and Delete, and a pushed pattern editor sets name, kind, cycle length, anchor date and the per-week weekday set. Edits send only the changed fields. The month grid shows the combined presence with the core's first-match rule: override, then any covering away pattern, then any covering home pattern, then away when the child has a home pattern, else home. The grid keeps the Sunday-first week; stored weekdays stay Monday=0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+58
-38
@@ -1,25 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* Typed client for the admin presence routes: a child's repeating schedule
|
* Typed client for the admin presence routes: a child's repeating patterns
|
||||||
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
|
* (/api/v1/admin/children/{id}/presence-patterns, /presence-patterns/{id})
|
||||||
* date-range overrides (/api/v1/admin/presence-overrides).
|
* and the household's date-range overrides (/api/v1/admin/presence-overrides).
|
||||||
*/
|
*/
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import { CHILDREN_PATH, readJson } from "./definitions";
|
import { CHILDREN_PATH, readJson } from "./definitions";
|
||||||
|
|
||||||
/**
|
/** A pattern claims its covered days as the child being home or away. */
|
||||||
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
|
export type PatternKind = "home" | "away";
|
||||||
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
|
|
||||||
*/
|
|
||||||
export type PresencePattern = Record<string, number[]>;
|
|
||||||
|
|
||||||
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<string, number[]>;
|
||||||
|
|
||||||
|
/** The anchor-based cycle every pattern repeats on. */
|
||||||
|
export interface PatternCycle {
|
||||||
cycle_length_weeks: number;
|
cycle_length_weeks: number;
|
||||||
/** "YYYY-MM-DD"; pins cycle week 0. */
|
/** "YYYY-MM-DD"; pins cycle week 0. */
|
||||||
anchor_date: string;
|
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;
|
child_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,30 +50,48 @@ export interface PresenceOverrideFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
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 {
|
export function childPatternsPath(childId: number): string {
|
||||||
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
|
return `${CHILDREN_PATH}/${childId}/presence-patterns`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The child's schedule, or null when it has none (present every day). */
|
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
||||||
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
|
return apiFetch(path, {
|
||||||
const body = await readJson<{ schedule: PresenceSchedule | null }>(
|
method,
|
||||||
await apiFetch(presenceSchedulePath(childId)),
|
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<PresencePattern[]> {
|
||||||
|
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 createPresencePattern(
|
||||||
export async function savePresenceSchedule(
|
|
||||||
childId: number,
|
childId: number,
|
||||||
body: PresenceScheduleBody,
|
body: PresencePatternBody,
|
||||||
): Promise<PresenceSchedule> {
|
): Promise<PresencePattern> {
|
||||||
return readJson<PresenceSchedule>(
|
return readJson<PresencePattern>(await sendJson(childPatternsPath(childId), "POST", body));
|
||||||
await apiFetch(presenceSchedulePath(childId), {
|
}
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
/** Change only the supplied fields; resolves to the pattern as stored. */
|
||||||
body: JSON.stringify(body),
|
export async function updatePresencePattern(
|
||||||
}),
|
patternId: number,
|
||||||
|
body: Partial<PresencePatternBody>,
|
||||||
|
): Promise<PresencePattern> {
|
||||||
|
return readJson<PresencePattern>(
|
||||||
|
await sendJson(`${PRESENCE_PATTERNS_PATH}/${patternId}`, "PATCH", body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePresencePattern(patternId: number): Promise<void> {
|
||||||
|
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`;
|
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
|
||||||
|
|
||||||
function postJson(path: string, body: unknown): Promise<Response> {
|
|
||||||
return apiFetch(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many open upcoming tasks saving this override would remove. Computed
|
* How many open upcoming tasks saving this override would remove. Computed
|
||||||
* by the API before anything is saved; completed instances never count.
|
* by the API before anything is saved; completed instances never count.
|
||||||
*/
|
*/
|
||||||
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
|
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
|
||||||
const body = await readJson<{ removed: number }>(
|
const body = await readJson<{ removed: number }>(
|
||||||
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
|
await sendJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, "POST", range),
|
||||||
);
|
);
|
||||||
return body.removed;
|
return body.removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
|
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
|
||||||
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
|
return readJson<PresenceOverride>(await sendJson(PRESENCE_OVERRIDES_PATH, "POST", body));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePresenceOverride(overrideId: number): Promise<void> {
|
export async function deletePresenceOverride(overrideId: number): Promise<void> {
|
||||||
|
|||||||
@@ -95,6 +95,15 @@
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
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 {
|
.override-segment {
|
||||||
min-height: var(--nq-a-tap-min);
|
min-height: var(--nq-a-tap-min);
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import App from "../App";
|
|||||||
import ScheduleTab from "./ScheduleTab";
|
import ScheduleTab from "./ScheduleTab";
|
||||||
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
||||||
import type { AdminChild } from "../api/definitions";
|
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 { storeTokens } from "../auth/oidc";
|
||||||
|
|
||||||
function child(id: number, display_name: string, sort_order: number): AdminChild {
|
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 CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
|
||||||
|
|
||||||
const DECLAN: PresenceSchedule = {
|
const DECLAN: PresencePattern = {
|
||||||
|
id: 1,
|
||||||
child_id: 1,
|
child_id: 1,
|
||||||
|
name: "Home weeks",
|
||||||
|
kind: "home",
|
||||||
cycle_length_weeks: 2,
|
cycle_length_weeks: 2,
|
||||||
anchor_date: "2026-09-07",
|
anchor_date: "2026-09-07",
|
||||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
||||||
@@ -72,9 +75,9 @@ async function routeFetch(url: string, init: RequestInit = {}): Promise<Response
|
|||||||
return jsonResponse({ status: "ok" });
|
return jsonResponse({ status: "ok" });
|
||||||
}
|
}
|
||||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||||
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
const patternsMatch = /\/children\/(\d+)\/presence-patterns$/.exec(path);
|
||||||
if (scheduleMatch) {
|
if (patternsMatch) {
|
||||||
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
|
return jsonResponse({ patterns: Number(patternsMatch[1]) === 1 ? [DECLAN] : [] });
|
||||||
}
|
}
|
||||||
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
|
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
|
||||||
return jsonResponse({ detail: "unexpected" }, 500);
|
return jsonResponse({ detail: "unexpected" }, 500);
|
||||||
@@ -293,7 +296,7 @@ describe("OverrideEditor", () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
const before = calls().length;
|
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());
|
fireEvent.click(saveButton());
|
||||||
const row = await screen.findByTestId("override-50");
|
const row = await screen.findByTestId("override-50");
|
||||||
@@ -321,10 +324,10 @@ describe("OverrideEditor", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(after.some((c) => c.method === "DELETE")).toBe(false);
|
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(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
|
||||||
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
|
expect(calls().filter((c) => c.path.endsWith("/presence-patterns")).length).toBeGreaterThan(
|
||||||
schedulesBefore,
|
patternsBefore,
|
||||||
);
|
);
|
||||||
// The grid reflects the new override.
|
// The grid reflects the new override.
|
||||||
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
|
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type Consequence =
|
|||||||
| { kind: "ready"; removed: number }
|
| { kind: "ready"; removed: number }
|
||||||
| { kind: "error"; message: string };
|
| { 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 ApiForbiddenError) return error.message;
|
||||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||||
return fallback;
|
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<HTMLElement>, root: HTMLElement | null): void {
|
||||||
|
if (event.key !== "Tab" || !root) return;
|
||||||
|
const focusable = Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const current = document.activeElement;
|
||||||
|
const outside = !focusable.includes(current as HTMLElement);
|
||||||
|
if (event.shiftKey && (current === first || outside)) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && (current === last || outside)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tasksPhrase(count: number): string {
|
function tasksPhrase(count: number): string {
|
||||||
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
||||||
}
|
}
|
||||||
@@ -118,20 +136,7 @@ export default function OverrideEditor({
|
|||||||
if (!saving) onCancel();
|
if (!saving) onCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key !== "Tab" || !screenRef.current) return;
|
trapTab(event, screenRef.current);
|
||||||
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
|
||||||
if (focusable.length === 0) return;
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
const current = document.activeElement;
|
|
||||||
const outside = !focusable.includes(current as HTMLElement);
|
|
||||||
if (event.shiftKey && (current === first || outside)) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && (current === last || outside)) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
|
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Presence pattern editor — a pushed screen like the override editor.
|
||||||
|
*
|
||||||
|
* Adds a pattern to the child's collection (POST) or edits one in place
|
||||||
|
* (PATCH with only the changed fields). The weekday numbering sent to the
|
||||||
|
* API stays Monday=0 .. Sunday=6; only the display is Sunday-first.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
|
import type { AdminChild } from "../api/definitions";
|
||||||
|
import {
|
||||||
|
createPresencePattern,
|
||||||
|
updatePresencePattern,
|
||||||
|
type PatternKind,
|
||||||
|
type PatternWeeks,
|
||||||
|
type PresencePattern,
|
||||||
|
type PresencePatternBody,
|
||||||
|
} from "../api/presence";
|
||||||
|
import {
|
||||||
|
DISPLAY_WEEKDAYS,
|
||||||
|
WEEKDAY_SHORT,
|
||||||
|
dayNumber,
|
||||||
|
emptyPattern,
|
||||||
|
isoFromDayNumber,
|
||||||
|
resizePattern,
|
||||||
|
togglePatternDay,
|
||||||
|
} from "./cycle";
|
||||||
|
import { anchorLabel, shortDate } from "./format";
|
||||||
|
import { ChevronLeftGlyph } from "./glyphs";
|
||||||
|
import { errorMessage, inertOutside, isIsoDate, trapTab } from "./OverrideEditor";
|
||||||
|
import "./OverrideEditor.css";
|
||||||
|
|
||||||
|
export const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
|
||||||
|
|
||||||
|
const WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||||
|
|
||||||
|
/** The fields of `next` that differ from the stored pattern. */
|
||||||
|
export function changedFields(
|
||||||
|
stored: PresencePatternBody,
|
||||||
|
next: PresencePatternBody,
|
||||||
|
): Partial<PresencePatternBody> {
|
||||||
|
const changes: Partial<PresencePatternBody> = {};
|
||||||
|
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<PatternKind>(pattern?.kind ?? "away");
|
||||||
|
const [cycleLength, setCycleLength] = useState(pattern?.cycle_length_weeks ?? 1);
|
||||||
|
const [anchor, setAnchor] = useState(pattern?.anchor_date ?? today);
|
||||||
|
const [weeks, setWeeks] = useState<PatternWeeks>(pattern?.pattern ?? emptyPattern(1));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
titleRef.current?.focus();
|
||||||
|
return screenRef.current ? inertOutside(screenRef.current) : undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!saving) onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trapTab(event, screenRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: PresencePatternBody = {
|
||||||
|
name: name.trim(),
|
||||||
|
kind,
|
||||||
|
cycle_length_weeks: cycleLength,
|
||||||
|
anchor_date: anchor,
|
||||||
|
pattern: resizePattern(weeks, cycleLength),
|
||||||
|
};
|
||||||
|
const changes = pattern ? changedFields(pattern, body) : body;
|
||||||
|
const coversADay = Object.values(body.pattern).some((days) => days.length > 0);
|
||||||
|
const canSave =
|
||||||
|
body.name !== "" && isIsoDate(anchor) && coversADay && Object.keys(changes).length > 0 && !saving;
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!canSave) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const request = pattern
|
||||||
|
? updatePresencePattern(pattern.id, changes)
|
||||||
|
: createPresencePattern(child.id, body);
|
||||||
|
request.then(onSaved).catch((err: unknown) => {
|
||||||
|
setError(errorMessage(err, "The pattern could not be saved. Check your connection and try again."));
|
||||||
|
setSaving(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = pattern ? "Edit pattern" : "New pattern";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={screenRef}
|
||||||
|
className="override-screen"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="pattern-title"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<header className="override-header">
|
||||||
|
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
|
||||||
|
<ChevronLeftGlyph size={20} />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 className="override-title" id="pattern-title" ref={titleRef} tabIndex={-1}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="override-sub">Repeating presence for {child.display_name}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="override-scroll">
|
||||||
|
<section className="override-card" aria-label="Pattern details">
|
||||||
|
<label className="override-field">
|
||||||
|
<span className="override-label">Name</span>
|
||||||
|
<input
|
||||||
|
className="override-input"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="override-field">
|
||||||
|
<span className="override-label" id="pattern-label-kind">
|
||||||
|
Covered days are
|
||||||
|
</span>
|
||||||
|
<div className="override-segments override-segments--2" role="group" aria-labelledby="pattern-label-kind">
|
||||||
|
{(["away", "home"] as PatternKind[]).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className="override-segment override-segment--ink"
|
||||||
|
aria-pressed={kind === value}
|
||||||
|
onClick={() => setKind(value)}
|
||||||
|
>
|
||||||
|
{value === "away" ? "Away" : "Home"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="override-dates">
|
||||||
|
<label className="override-field">
|
||||||
|
<span className="override-label">Cycle length</span>
|
||||||
|
<select
|
||||||
|
className="override-input"
|
||||||
|
value={cycleLength}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
setCycleLength(next);
|
||||||
|
setWeeks((current) => resizePattern(current, next));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CYCLE_LENGTH_CHOICES.map((count) => (
|
||||||
|
<option key={count} value={count}>
|
||||||
|
{count} {count === 1 ? "week" : "weeks"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="override-field">
|
||||||
|
<span className="override-label">Anchor date</span>
|
||||||
|
<input
|
||||||
|
className="override-input override-date"
|
||||||
|
type="date"
|
||||||
|
value={anchor}
|
||||||
|
onChange={(event) => setAnchor(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.from({ length: cycleLength }, (_, week) => {
|
||||||
|
const days = body.pattern[week];
|
||||||
|
const from = isIsoDate(anchor)
|
||||||
|
? shortDate(isoFromDayNumber(dayNumber(anchor) + week * 7))
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div key={week} className="override-field">
|
||||||
|
<span className="override-label" id={`pattern-label-week-${week}`}>
|
||||||
|
Week {week + 1}
|
||||||
|
{from ? ` · from ${from}` : ""}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="override-segments override-segments--7"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby={`pattern-label-week-${week}`}
|
||||||
|
>
|
||||||
|
{DISPLAY_WEEKDAYS.map((weekday) => (
|
||||||
|
<button
|
||||||
|
key={weekday}
|
||||||
|
type="button"
|
||||||
|
className="override-segment override-segment--ink"
|
||||||
|
aria-pressed={days.includes(weekday)}
|
||||||
|
aria-label={WEEKDAY_NAMES[weekday]}
|
||||||
|
onClick={() => setWeeks((current) => togglePatternDay(current, week, weekday))}
|
||||||
|
>
|
||||||
|
{WEEKDAY_SHORT[weekday].slice(0, 2)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="override-sub">
|
||||||
|
{isIsoDate(anchor)
|
||||||
|
? `Week 1 starts ${anchorLabel(anchor)}; the cycle repeats from there.`
|
||||||
|
: "Choose an anchor date."}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="override-error" role="alert" data-testid="pattern-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="override-footer">
|
||||||
|
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
|
||||||
|
{saving ? "Saving…" : "Save pattern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -119,14 +119,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-day {
|
.schedule-day {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
padding: 0;
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-day--home {
|
.schedule-day--home {
|
||||||
@@ -184,60 +183,11 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pattern editing controls */
|
.schedule-legend-note {
|
||||||
.schedule-controls {
|
margin: 8px 0 0;
|
||||||
display: flex;
|
font-size: 10.5px;
|
||||||
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;
|
font-weight: 600;
|
||||||
text-transform: none;
|
color: var(--nq-a-ink-tertiary);
|
||||||
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 {
|
.schedule-save-status {
|
||||||
@@ -251,7 +201,7 @@
|
|||||||
color: var(--nq-a-danger-strong);
|
color: var(--nq-a-danger-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Presence pattern + overrides cards */
|
/* Presence patterns + overrides cards */
|
||||||
.schedule-section-head {
|
.schedule-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -318,13 +268,6 @@
|
|||||||
color: var(--nq-a-ink-secondary);
|
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 {
|
.schedule-icon-action {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -339,6 +282,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-icon-action--edit {
|
||||||
|
margin-right: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-empty {
|
.schedule-empty {
|
||||||
padding: var(--nq-a-density-card-pad);
|
padding: var(--nq-a-density-card-pad);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 ScheduleTab from "./ScheduleTab";
|
||||||
import type { AdminChild } from "../api/definitions";
|
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 { storeTokens } from "../auth/oidc";
|
||||||
|
import { cycleWeekIndex, monthDates, weekdayOf } from "./cycle";
|
||||||
|
|
||||||
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
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 };
|
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
|
||||||
@@ -16,20 +17,39 @@ const CHILDREN = [
|
|||||||
child(9, "Former", 4, false),
|
child(9, "Former", 4, false),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
|
// Declan: the migrated single schedule — one `home` pattern anchored Monday
|
||||||
// Keys arrive as JSON strings, exactly as the API serializes them.
|
// 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun only. Keys arrive
|
||||||
const DECLAN: PresenceSchedule = {
|
// as JSON strings, exactly as the API serializes them.
|
||||||
|
const DECLAN_HOME: PresencePattern = {
|
||||||
|
id: 1,
|
||||||
child_id: 1,
|
child_id: 1,
|
||||||
|
name: "Home weeks",
|
||||||
|
kind: "home",
|
||||||
cycle_length_weeks: 2,
|
cycle_length_weeks: 2,
|
||||||
anchor_date: "2026-09-07",
|
anchor_date: "2026-09-07",
|
||||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
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,
|
child_id: 2,
|
||||||
cycle_length_weeks: 4,
|
name: "Thursday+Friday",
|
||||||
|
kind: "away",
|
||||||
|
cycle_length_weeks: 1,
|
||||||
anchor_date: "2026-09-07",
|
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[] = [
|
const OVERRIDES: PresenceOverride[] = [
|
||||||
@@ -39,6 +59,7 @@ const OVERRIDES: PresenceOverride[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TODAY = "2026-09-23";
|
const TODAY = "2026-09-23";
|
||||||
|
const PATTERNS_PATH = "/api/v1/admin/presence-patterns";
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200): Response {
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
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<number, PresencePattern[]>;
|
||||||
|
let nextId: number;
|
||||||
|
let writeFailure: Response | null;
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
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 {
|
function routeFetch(url: string, init: RequestInit = {}): Response {
|
||||||
const path = new URL(url, "http://x").pathname;
|
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 });
|
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||||
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
const collection = /\/children\/(\d+)\/presence-patterns$/.exec(path);
|
||||||
if (match) {
|
if (collection) {
|
||||||
const id = Number(match[1]);
|
const childId = Number(collection[1]);
|
||||||
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
|
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 });
|
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
|
||||||
return jsonResponse({ detail: "unexpected" }, 500);
|
return jsonResponse({ detail: "unexpected" }, 500);
|
||||||
@@ -69,16 +142,32 @@ function day(date: string): HTMLElement {
|
|||||||
return cell;
|
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() {
|
async function renderReady() {
|
||||||
render(<ScheduleTab today={TODAY} />);
|
render(<ScheduleTab today={TODAY} />);
|
||||||
await screen.findByTestId("pattern-1");
|
await screen.findByTestId("pattern-1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectMaeve() {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||||
|
await screen.findByTestId("pattern-3");
|
||||||
|
}
|
||||||
|
|
||||||
describe("ScheduleTab", () => {
|
describe("ScheduleTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
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));
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
});
|
});
|
||||||
@@ -88,27 +177,31 @@ describe("ScheduleTab", () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads active children, their schedules and the overrides", async () => {
|
it("loads active children, their patterns and the overrides", async () => {
|
||||||
await renderReady();
|
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");
|
||||||
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
|
expect(urls).toContain("/api/v1/admin/children/1/presence-patterns");
|
||||||
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
|
expect(urls).toContain("/api/v1/admin/children/2/presence-patterns");
|
||||||
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
|
expect(urls).toContain("/api/v1/admin/children/3/presence-patterns");
|
||||||
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
|
expect(urls).not.toContain("/api/v1/admin/children/9/presence-patterns");
|
||||||
expect(urls).toContain("/api/v1/admin/presence-overrides");
|
expect(urls).toContain("/api/v1/admin/presence-overrides");
|
||||||
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
||||||
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
|
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
|
||||||
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
|
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();
|
await renderReady();
|
||||||
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
|
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();
|
await renderReady();
|
||||||
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
||||||
const states: Record<string, string> = {
|
const states: Record<string, string> = {
|
||||||
@@ -131,6 +224,8 @@ describe("ScheduleTab", () => {
|
|||||||
expect(today.className).toContain("schedule-day--today");
|
expect(today.className).toContain("schedule-day--today");
|
||||||
expect(today.dataset.state).toBe("home");
|
expect(today.dataset.state).toBe("home");
|
||||||
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): 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.
|
// Sunday-first header; September 2026 starts on a Tuesday: two leading pads.
|
||||||
const grid = screen.getByRole("group", { name: "Month grid" });
|
const grid = screen.getByRole("group", { name: "Month grid" });
|
||||||
expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([
|
expect(Array.from(grid.children, (c) => c.textContent).slice(0, 7)).toEqual([
|
||||||
@@ -142,8 +237,8 @@ describe("ScheduleTab", () => {
|
|||||||
"F",
|
"F",
|
||||||
"S",
|
"S",
|
||||||
]);
|
]);
|
||||||
expect(grid.children[7].tagName).toBe("SPAN");
|
expect(grid.children[7].getAttribute("data-date")).toBeNull();
|
||||||
expect(grid.children[8].tagName).toBe("SPAN");
|
expect(grid.children[8].getAttribute("data-date")).toBeNull();
|
||||||
expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01");
|
expect(grid.children[9].getAttribute("data-date")).toBe("2026-09-01");
|
||||||
|
|
||||||
const legend = screen.getByRole("list", { name: "Legend" });
|
const legend = screen.getByRole("list", { name: "Legend" });
|
||||||
@@ -154,6 +249,29 @@ describe("ScheduleTab", () => {
|
|||||||
"Override · home",
|
"Override · home",
|
||||||
"Today",
|
"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 () => {
|
it("navigates months with the chevrons", async () => {
|
||||||
@@ -168,14 +286,36 @@ describe("ScheduleTab", () => {
|
|||||||
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("presence pattern card shows a rule per child", async () => {
|
it("patterns list shows each pattern's name, kind, cycle and weekday set", async () => {
|
||||||
await renderReady();
|
await selectMaeve();
|
||||||
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
const list = screen.getByRole("list", { name: "Presence patterns" });
|
||||||
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
|
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||||
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
|
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();
|
await renderReady();
|
||||||
const list = screen.getByRole("list", { name: "Overrides" });
|
const list = screen.getByRole("list", { name: "Overrides" });
|
||||||
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||||
@@ -188,86 +328,233 @@ describe("ScheduleTab", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||||
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
|
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();
|
await renderReady();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
|
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
|
||||||
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
|
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
expect(await screen.findByRole("heading", { name: "New pattern" })).toBeTruthy();
|
||||||
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
|
// Nothing named and no day covered yet.
|
||||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
expect(saveButton().disabled).toBe(true);
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: " Dad's weekends " } });
|
||||||
await renderReady();
|
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "2" } });
|
||||||
expect(day("2026-09-01").dataset.state).toBe("away");
|
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-07" } });
|
||||||
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
|
// Sunday-first buttons; the body keeps Monday=0 numbering.
|
||||||
|
const week1 = weekGroup(1);
|
||||||
|
expect(within(week1).getAllByRole("button").map((b) => b.textContent)).toEqual([
|
||||||
|
"Su",
|
||||||
|
"Mo",
|
||||||
|
"Tu",
|
||||||
|
"We",
|
||||||
|
"Th",
|
||||||
|
"Fr",
|
||||||
|
"Sa",
|
||||||
|
]);
|
||||||
|
fireEvent.click(within(week1).getByRole("button", { name: "Saturday" }));
|
||||||
|
fireEvent.click(within(week1).getByRole("button", { name: "Sunday" }));
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
|
||||||
// The draft already shows the edit, and every other week-1 Tuesday follows.
|
const before = calls().length;
|
||||||
expect(day("2026-09-01").dataset.state).toBe("home");
|
fireEvent.click(saveButton());
|
||||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
const row = await screen.findByTestId("pattern-50");
|
||||||
expect(day("2026-09-08").dataset.state).toBe("home");
|
expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
expect(writes()).toEqual([
|
||||||
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
|
{
|
||||||
|
method: "POST",
|
||||||
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
|
path: "/api/v1/admin/children/3/presence-patterns",
|
||||||
expect(put).toBeTruthy();
|
body: {
|
||||||
const [url, init] = put!;
|
name: "Dad's weekends",
|
||||||
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
|
kind: "away",
|
||||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
cycle_length_weeks: 2,
|
||||||
expect(JSON.parse(init.body)).toEqual({
|
anchor_date: "2026-09-07",
|
||||||
cycle_length_weeks: 2,
|
pattern: { "0": [5, 6], "1": [] },
|
||||||
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],
|
|
||||||
},
|
},
|
||||||
});
|
]);
|
||||||
|
const after = calls().slice(before);
|
||||||
|
expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/3/presence-patterns")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(within(row).getByText("Dad's weekends")).toBeTruthy();
|
||||||
|
expect(within(row).getByText("Away · Every 2 weeks")).toBeTruthy();
|
||||||
|
expect(day("2026-09-12").dataset.state).toBe("away");
|
||||||
|
expect(day("2026-09-19").dataset.state).toBe("home");
|
||||||
|
// Focus returns to the Add control of the patterns section.
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a 422 shows the API detail and keeps the draft", async () => {
|
it("Add can save a Home pattern", async () => {
|
||||||
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
|
|
||||||
await renderReady();
|
await renderReady();
|
||||||
fireEvent.click(day("2026-09-01"));
|
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
|
await screen.findByRole("heading", { name: "New pattern" });
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "School nights" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||||
|
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await screen.findByTestId("pattern-50");
|
||||||
|
expect(writes()[0].body).toEqual({
|
||||||
|
name: "School nights",
|
||||||
|
kind: "home",
|
||||||
|
cycle_length_weeks: 1,
|
||||||
|
anchor_date: TODAY,
|
||||||
|
pattern: { "0": [0] },
|
||||||
|
});
|
||||||
|
// A home pattern: uncovered days are now away.
|
||||||
|
expect(day("2026-09-14").dataset.state).toBe("home"); // Monday
|
||||||
|
expect(day("2026-09-15").dataset.state).toBe("away"); // Tuesday
|
||||||
|
});
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
it("Edit PATCHes only the changed fields to /presence-patterns/{id} and refreshes", async () => {
|
||||||
expect(alert.textContent).toBe("anchor_date must be a Monday");
|
await selectMaeve();
|
||||||
expect(day("2026-09-01").dataset.state).toBe("home");
|
fireEvent.click(screen.getByRole("button", { name: "Edit Alternate weekends" }));
|
||||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
expect(await screen.findByRole("heading", { name: "Edit pattern" })).toBeTruthy();
|
||||||
false,
|
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Alternate weekends");
|
||||||
|
expect((screen.getByLabelText("Cycle length") as HTMLSelectElement).value).toBe("2");
|
||||||
|
expect((screen.getByLabelText("Anchor date") as HTMLInputElement).value).toBe("2026-09-07");
|
||||||
|
expect(within(weekGroup(1)).getByRole("button", { name: "Saturday" }).getAttribute("aria-pressed")).toBe(
|
||||||
|
"true",
|
||||||
);
|
);
|
||||||
// The saved rule is unchanged.
|
// Unchanged: nothing to save.
|
||||||
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Every other weekend" } });
|
||||||
|
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Sunday" }));
|
||||||
|
const before = calls().length;
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
|
||||||
|
|
||||||
|
expect(writes()).toEqual([
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
path: `${PATTERNS_PATH}/3`,
|
||||||
|
body: { name: "Every other weekend", pattern: { "0": [5], "1": [] } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const after = calls().slice(before);
|
||||||
|
expect(after.some((c) => c.method === "GET" && c.path === "/api/v1/admin/children/2/presence-patterns")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = await screen.findByText("Every other weekend");
|
||||||
|
expect(within(row.closest("li")!).getByText("Week 1: Sat · Week 2: No days")).toBeTruthy();
|
||||||
|
expect(day("2026-09-12").dataset.state).toBe("away"); // Sat, on week
|
||||||
|
expect(day("2026-09-13").dataset.state).toBe("home"); // Sun no longer covered
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Delete asks first, DELETEs /presence-patterns/{id} and refreshes the list", async () => {
|
||||||
|
await selectMaeve();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
|
||||||
|
const confirm = within(screen.getByTestId("pattern-2")).getByRole("group", { name: "Confirm delete" });
|
||||||
|
fireEvent.click(within(confirm).getByRole("button", { name: "Keep" }));
|
||||||
|
expect(writes()).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete Thursday+Friday" }));
|
||||||
|
fireEvent.click(within(screen.getByTestId("pattern-2")).getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("pattern-2")).toBeNull());
|
||||||
|
expect(writes()).toEqual([{ method: "DELETE", path: `${PATTERNS_PATH}/2`, body: undefined }]);
|
||||||
|
expect(screen.getByTestId("pattern-3")).toBeTruthy();
|
||||||
|
expect(day("2026-09-17").dataset.state).toBe("home"); // Thursday, no longer away
|
||||||
|
expect(day("2026-09-12").dataset.state).toBe("away"); // weekends pattern still applies
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting the last pattern leaves the child with none: home every day", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(day("2026-09-15").dataset.state).toBe("away");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
|
||||||
|
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
|
||||||
|
expect(await screen.findByTestId("patterns-empty")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("list", { name: "Presence patterns" })).toBeNull();
|
||||||
|
expect(patterns[1]).toEqual([]);
|
||||||
|
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||||
|
expect(day("2026-09-11").dataset.state).toBe("override-away"); // overrides still win
|
||||||
|
expect(screen.getByText("Declan · no patterns, home every day")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 422 on save shows the API detail naming the field and keeps the editor open", async () => {
|
||||||
|
writeFailure = jsonResponse({ detail: "name must not be empty" }, 422);
|
||||||
|
await selectMaeve();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit Thursday+Friday" }));
|
||||||
|
await screen.findByRole("heading", { name: "Edit pattern" });
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Thu/Fri" } });
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
const alert = await screen.findByTestId("pattern-error");
|
||||||
|
expect(alert.getAttribute("role")).toBe("alert");
|
||||||
|
expect(alert.textContent).toBe("name must not be empty");
|
||||||
|
expect(screen.getByRole("heading", { name: "Edit pattern" })).toBeTruthy();
|
||||||
|
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("Thu/Fri");
|
||||||
|
expect(saveButton().disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 403 on add is surfaced as the refusal, not swallowed", async () => {
|
||||||
|
writeFailure = new Response("", { status: 403 });
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
|
await screen.findByRole("heading", { name: "New pattern" });
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Fridays" } });
|
||||||
|
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Friday" }));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
const alert = await screen.findByTestId("pattern-error");
|
||||||
|
expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
|
||||||
|
expect(patterns[1]).toEqual([DECLAN_HOME]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 403 on delete is surfaced and the pattern stays listed", async () => {
|
||||||
|
writeFailure = new Response("", { status: 403 });
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
|
||||||
|
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("Your account is not in the nestquest-admins group.");
|
||||||
|
expect(screen.getByTestId("pattern-1")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 422 on delete shows the API detail", async () => {
|
||||||
|
writeFailure = jsonResponse({ detail: "pattern_id must be an integer" }, 422);
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete Home weeks" }));
|
||||||
|
fireEvent.click(within(screen.getByTestId("pattern-1")).getByRole("button", { name: "Delete" }));
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toBe("pattern_id must be an integer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel closes the editor without any request", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
|
await screen.findByRole("heading", { name: "New pattern" });
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Draft" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(screen.queryByRole("heading", { name: "New pattern" })).toBeNull();
|
||||||
|
expect(writes()).toEqual([]);
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never calls the removed one-per-child /presence-schedule route", async () => {
|
||||||
|
await selectMaeve();
|
||||||
|
// Add, edit and delete: every pattern path the screen can take.
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add pattern" }));
|
||||||
|
await screen.findByRole("heading", { name: "New pattern" });
|
||||||
|
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Mondays" } });
|
||||||
|
fireEvent.click(within(weekGroup(1)).getByRole("button", { name: "Monday" }));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await screen.findByTestId("pattern-50");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit Mondays" }));
|
||||||
|
await screen.findByRole("heading", { name: "Edit pattern" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
await waitFor(() => expect(screen.queryByRole("heading", { name: "Edit pattern" })).toBeNull());
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Delete Mondays" }));
|
||||||
|
fireEvent.click(within(screen.getByTestId("pattern-50")).getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("pattern-50")).toBeNull());
|
||||||
|
|
||||||
|
expect(writes().map((c) => `${c.method} ${c.path}`)).toEqual([
|
||||||
|
"POST /api/v1/admin/children/2/presence-patterns",
|
||||||
|
`PATCH ${PATTERNS_PATH}/50`,
|
||||||
|
`DELETE ${PATTERNS_PATH}/50`,
|
||||||
|
]);
|
||||||
|
expect(calls().filter((c) => c.path.includes("presence-schedule"))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scroll column ends with 92px bottom padding", async () => {
|
it("scroll column ends with 92px bottom padding", async () => {
|
||||||
|
|||||||
+193
-165
@@ -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
|
* The grid shows the COMBINED presence of every pattern (./cycle.ts
|
||||||
* from ISO week numbers or week parity (D-004).
|
* `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 { useEffect, useRef, useState } from "react";
|
||||||
import { ApiForbiddenError } from "../api/client";
|
import { ApiForbiddenError } from "../api/client";
|
||||||
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
||||||
import {
|
import {
|
||||||
deletePresenceOverride,
|
deletePresenceOverride,
|
||||||
|
deletePresencePattern,
|
||||||
fetchPresenceOverrides,
|
fetchPresenceOverrides,
|
||||||
fetchPresenceSchedule,
|
fetchPresencePatterns,
|
||||||
savePresenceSchedule,
|
|
||||||
type PresenceOverride,
|
type PresenceOverride,
|
||||||
type PresenceSchedule,
|
type PresencePattern,
|
||||||
type PresenceScheduleBody,
|
|
||||||
} from "../api/presence";
|
} from "../api/presence";
|
||||||
import {
|
import {
|
||||||
cycleWeekIndex,
|
cycleLabel,
|
||||||
dayState,
|
dayState,
|
||||||
fullPattern,
|
|
||||||
localTodayIso,
|
localTodayIso,
|
||||||
monthDates,
|
monthDates,
|
||||||
monthStart,
|
monthStart,
|
||||||
patternIncludes,
|
patternDaysLabel,
|
||||||
presenceRule,
|
|
||||||
resizePattern,
|
|
||||||
togglePatternDay,
|
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
type DayState,
|
type DayState,
|
||||||
} from "./cycle";
|
} from "./cycle";
|
||||||
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
|
import { dateRange, monthLabel, shortDate } from "./format";
|
||||||
import {
|
import {
|
||||||
CalendarCheckGlyph,
|
CalendarCheckGlyph,
|
||||||
CalendarXGlyph,
|
CalendarXGlyph,
|
||||||
ChevronLeftGlyph,
|
ChevronLeftGlyph,
|
||||||
ChevronRightGlyph,
|
ChevronRightGlyph,
|
||||||
|
PencilGlyph,
|
||||||
Trash2Glyph,
|
Trash2Glyph,
|
||||||
} from "./glyphs";
|
} from "./glyphs";
|
||||||
import OverrideEditor from "./OverrideEditor";
|
import OverrideEditor from "./OverrideEditor";
|
||||||
|
import PatternEditor from "./PatternEditor";
|
||||||
import "./ScheduleTab.css";
|
import "./ScheduleTab.css";
|
||||||
|
|
||||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
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. */
|
/** Sunday-first header; `weekdayOf` stays Monday=0. */
|
||||||
const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
|
const WEEKDAY_INITIALS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
@@ -58,7 +55,7 @@ export const STATE_LABELS: Record<DayState, string> = {
|
|||||||
|
|
||||||
interface Loaded {
|
interface Loaded {
|
||||||
children: AdminChild[];
|
children: AdminChild[];
|
||||||
schedules: Record<number, PresenceSchedule | null>;
|
patterns: Record<number, PresencePattern[]>;
|
||||||
overrides: PresenceOverride[];
|
overrides: PresenceOverride[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,23 +70,22 @@ type DeleteState =
|
|||||||
| { kind: "deleting"; id: number }
|
| { kind: "deleting"; id: number }
|
||||||
| { kind: "error"; id: number; message: string };
|
| { kind: "error"; id: number; message: string };
|
||||||
|
|
||||||
type SaveState =
|
type EditorState =
|
||||||
| { kind: "idle" }
|
| { kind: "closed" }
|
||||||
| { kind: "saving" }
|
| { kind: "override" }
|
||||||
| { kind: "saved" }
|
| { kind: "pattern"; pattern: PresencePattern | null };
|
||||||
| { kind: "error"; message: string };
|
|
||||||
|
|
||||||
async function loadSchedule(): Promise<Loaded> {
|
async function loadSchedule(): Promise<Loaded> {
|
||||||
const children = (await fetchChildren())
|
const children = (await fetchChildren())
|
||||||
.filter((child) => child.is_active)
|
.filter((child) => child.is_active)
|
||||||
.sort((a, b) => a.sort_order - b.sort_order);
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
const [schedules, overrides] = await Promise.all([
|
const [patterns, overrides] = await Promise.all([
|
||||||
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
|
Promise.all(children.map((child) => fetchPresencePatterns(child.id))),
|
||||||
fetchPresenceOverrides(),
|
fetchPresenceOverrides(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
children,
|
children,
|
||||||
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
|
patterns: Object.fromEntries(children.map((child, i) => [child.id, patterns[i]])),
|
||||||
overrides,
|
overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,10 +96,7 @@ function loadErrorMessage(error: unknown): string {
|
|||||||
: "The schedule could not be loaded. Check your connection and try again.";
|
: "The schedule could not be loaded. Check your connection and try again.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveErrorMessage(
|
function saveErrorMessage(error: unknown, fallback: string): string {
|
||||||
error: unknown,
|
|
||||||
fallback = "The pattern could not be saved. Check your connection and try again.",
|
|
||||||
): string {
|
|
||||||
if (error instanceof ApiForbiddenError) return error.message;
|
if (error instanceof ApiForbiddenError) return error.message;
|
||||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -121,15 +114,13 @@ function ScreenHeader({ sub }: { sub: string }) {
|
|||||||
interface MonthGridProps {
|
interface MonthGridProps {
|
||||||
month: string;
|
month: string;
|
||||||
today: string;
|
today: string;
|
||||||
schedule: PresenceScheduleBody;
|
/** Every pattern of the child; none means home every day. */
|
||||||
/** Null when the child has no saved schedule and no edits: home every day. */
|
patterns: PresencePattern[];
|
||||||
effective: PresenceScheduleBody | null;
|
|
||||||
overrides: PresenceOverride[];
|
overrides: PresenceOverride[];
|
||||||
onMonth: (delta: number) => void;
|
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 dates = monthDates(month);
|
||||||
const leading = (weekdayOf(month) + 1) % 7;
|
const leading = (weekdayOf(month) + 1) % 7;
|
||||||
return (
|
return (
|
||||||
@@ -167,23 +158,21 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
|
|||||||
<span key={`pad-${i}`} aria-hidden="true" />
|
<span key={`pad-${i}`} aria-hidden="true" />
|
||||||
))}
|
))}
|
||||||
{dates.map((date) => {
|
{dates.map((date) => {
|
||||||
const state = dayState(effective, overrides, date);
|
const state = dayState(patterns, overrides, date);
|
||||||
const isToday = date === today;
|
const isToday = date === today;
|
||||||
let className = `schedule-day schedule-day--${state}`;
|
let className = `schedule-day schedule-day--${state}`;
|
||||||
if (isToday) className += " schedule-day--today";
|
if (isToday) className += " schedule-day--today";
|
||||||
return (
|
return (
|
||||||
<button
|
<span
|
||||||
key={date}
|
key={date}
|
||||||
type="button"
|
role="img"
|
||||||
className={className}
|
className={className}
|
||||||
data-date={date}
|
data-date={date}
|
||||||
data-state={state}
|
data-state={state}
|
||||||
aria-pressed={patternIncludes(schedule, date)}
|
|
||||||
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
|
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
|
||||||
onClick={() => onToggle(date)}
|
|
||||||
>
|
>
|
||||||
{Number(date.slice(8))}
|
{Number(date.slice(8))}
|
||||||
</button>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,10 +188,83 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
|
|||||||
Today
|
Today
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p className="schedule-legend-note">
|
||||||
|
Combined from every pattern: away beats home; overrides beat both.
|
||||||
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="schedule-row" data-testid={`pattern-${pattern.id}`}>
|
||||||
|
<span className="schedule-row-glyph">
|
||||||
|
{pattern.kind === "home" ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
|
||||||
|
</span>
|
||||||
|
<div className="schedule-row-text">
|
||||||
|
<div className="schedule-row-title">{pattern.name}</div>
|
||||||
|
<div className="schedule-row-meta">
|
||||||
|
{pattern.kind === "home" ? "Home" : "Away"} · {cycleLabel(pattern.cycle_length_weeks)}
|
||||||
|
</div>
|
||||||
|
<div className="schedule-row-meta" data-testid={`pattern-days-${pattern.id}`}>
|
||||||
|
{patternDaysLabel(pattern)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mine ? (
|
||||||
|
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
|
||||||
|
<span className="schedule-confirm-text">Delete pattern?</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-confirm-keep"
|
||||||
|
onClick={onKeep}
|
||||||
|
disabled={deletion.kind === "deleting"}
|
||||||
|
>
|
||||||
|
Keep
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-confirm-delete"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deletion.kind === "deleting"}
|
||||||
|
>
|
||||||
|
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-icon-action schedule-icon-action--edit"
|
||||||
|
aria-label={`Edit ${pattern.name}`}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<PencilGlyph />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-icon-action"
|
||||||
|
aria-label={`Delete ${pattern.name}`}
|
||||||
|
onClick={onAskDelete}
|
||||||
|
>
|
||||||
|
<Trash2Glyph />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface OverrideRowProps {
|
interface OverrideRowProps {
|
||||||
override: PresenceOverride;
|
override: PresenceOverride;
|
||||||
childName: string;
|
childName: string;
|
||||||
@@ -271,21 +333,24 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [month, setMonth] = useState(() => monthStart(today));
|
const [month, setMonth] = useState(() => monthStart(today));
|
||||||
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
|
const [editor, setEditor] = useState<EditorState>({ kind: "closed" });
|
||||||
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
|
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
|
||||||
|
const [patternDeletion, setPatternDeletion] = useState<DeleteState>({ kind: "idle" });
|
||||||
const addRef = useRef<HTMLButtonElement>(null);
|
const addRef = useRef<HTMLButtonElement>(null);
|
||||||
const editorWasOpen = useRef(false);
|
const addPatternRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const openedEditor = useRef<EditorState["kind"]>("closed");
|
||||||
|
|
||||||
// The editor replaces this screen, so Add is remounted when it closes;
|
// An editor replaces this screen, so the Add controls are remounted when
|
||||||
// hand focus back to it then.
|
// it closes; hand focus back to the one that belongs to that editor.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
|
if (editor.kind === "closed") {
|
||||||
editorWasOpen.current = editorOpen;
|
if (openedEditor.current === "override") addRef.current?.focus();
|
||||||
}, [editorOpen]);
|
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 = () =>
|
const refresh = () =>
|
||||||
loadSchedule()
|
loadSchedule()
|
||||||
.then((data) => setState({ kind: "ready", data }))
|
.then((data) => setState({ kind: "ready", data }))
|
||||||
@@ -309,16 +374,31 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
};
|
};
|
||||||
}, [attempt]);
|
}, [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 { children } = state.data;
|
||||||
|
const current = children.find((c) => c.id === selectedId) ?? children[0];
|
||||||
|
if (editor.kind === "pattern") {
|
||||||
|
return (
|
||||||
|
<PatternEditor
|
||||||
|
child={current}
|
||||||
|
pattern={editor.pattern}
|
||||||
|
today={today}
|
||||||
|
onCancel={() => setEditor({ kind: "closed" })}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditor({ kind: "closed" });
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<OverrideEditor
|
<OverrideEditor
|
||||||
childOptions={children}
|
childOptions={children}
|
||||||
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
|
initialChildId={current.id}
|
||||||
today={today}
|
today={today}
|
||||||
onCancel={() => setEditorOpen(false)}
|
onCancel={() => setEditor({ kind: "closed" })}
|
||||||
onSaved={(override) => {
|
onSaved={(override) => {
|
||||||
setEditorOpen(false);
|
setEditor({ kind: "closed" });
|
||||||
setSelectedId(override.child_id);
|
setSelectedId(override.child_id);
|
||||||
void refresh();
|
void refresh();
|
||||||
}}
|
}}
|
||||||
@@ -348,28 +428,13 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
} else {
|
} else {
|
||||||
const { data } = state;
|
const { data } = state;
|
||||||
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
|
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
|
||||||
const saved = data.schedules[child.id] ?? null;
|
const patterns = data.patterns[child.id] ?? [];
|
||||||
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);
|
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) => {
|
const confirmDelete = (id: number) => {
|
||||||
setDeletion({ kind: "deleting", id });
|
setDeletion({ kind: "deleting", id });
|
||||||
deletePresenceOverride(id)
|
deletePresenceOverride(id)
|
||||||
@@ -386,32 +451,21 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const submit = () => {
|
const confirmPatternDelete = (id: number) => {
|
||||||
if (!draft) return;
|
setPatternDeletion({ kind: "deleting", id });
|
||||||
const childId = child.id;
|
deletePresencePattern(id)
|
||||||
setSave({ kind: "saving" });
|
.then(() => refresh())
|
||||||
const { cycle_length_weeks, anchor_date, pattern } = draft;
|
.then(() => setPatternDeletion({ kind: "idle" }))
|
||||||
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
|
.catch((error: unknown) =>
|
||||||
.then((stored) => {
|
setPatternDeletion({
|
||||||
setState((current) =>
|
kind: "error",
|
||||||
current.kind === "ready"
|
id,
|
||||||
? {
|
message: saveErrorMessage(
|
||||||
kind: "ready",
|
error,
|
||||||
data: {
|
"The pattern could not be deleted. Check your connection and try again.",
|
||||||
...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 = (
|
body = (
|
||||||
@@ -425,7 +479,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
aria-pressed={c.id === child.id}
|
aria-pressed={c.id === child.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedId(c.id);
|
setSelectedId(c.id);
|
||||||
setSave({ kind: "idle" });
|
setPatternDeletion({ kind: "idle" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{c.display_name}
|
{c.display_name}
|
||||||
@@ -437,74 +491,48 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
<MonthGrid
|
<MonthGrid
|
||||||
month={month}
|
month={month}
|
||||||
today={today}
|
today={today}
|
||||||
schedule={schedule}
|
patterns={patterns}
|
||||||
effective={effective}
|
|
||||||
overrides={childOverrides}
|
overrides={childOverrides}
|
||||||
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
|
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
|
||||||
onToggle={toggle}
|
|
||||||
/>
|
/>
|
||||||
<div className="schedule-controls">
|
|
||||||
<label className="schedule-field">
|
|
||||||
<span>Anchor date</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={schedule.anchor_date}
|
|
||||||
onChange={(event) => {
|
|
||||||
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="schedule-field">
|
|
||||||
<span>Cycle length</span>
|
|
||||||
<select
|
|
||||||
value={schedule.cycle_length_weeks}
|
|
||||||
onChange={(event) => {
|
|
||||||
const weeks = Number(event.target.value);
|
|
||||||
edit({
|
|
||||||
...schedule,
|
|
||||||
cycle_length_weeks: weeks,
|
|
||||||
pattern: resizePattern(schedule.pattern, weeks),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{CYCLE_LENGTH_CHOICES.map((weeks) => (
|
|
||||||
<option key={weeks} value={weeks}>
|
|
||||||
{weeks} {weeks === 1 ? "week" : "weeks"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-save"
|
|
||||||
disabled={!draft || save.kind === "saving"}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
{save.kind === "saving" ? "Saving…" : "Save pattern"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{save.kind === "error" ? (
|
|
||||||
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
|
||||||
{save.message}
|
|
||||||
</p>
|
|
||||||
) : save.kind === "saved" ? (
|
|
||||||
<p className="schedule-save-status" role="status">
|
|
||||||
Pattern saved.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<h3 className="schedule-section-label">Presence pattern</h3>
|
<div className="schedule-section-head">
|
||||||
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
|
<h3 className="schedule-section-label">Presence patterns</h3>
|
||||||
{data.children.map((c) => (
|
<button
|
||||||
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
|
ref={addPatternRef}
|
||||||
<div className="schedule-row-text">
|
type="button"
|
||||||
<div className="schedule-row-title">{c.display_name}</div>
|
className="schedule-text-action"
|
||||||
</div>
|
aria-label="Add pattern"
|
||||||
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
|
onClick={() => setEditor({ kind: "pattern", pattern: null })}
|
||||||
</li>
|
>
|
||||||
))}
|
Add
|
||||||
</ul>
|
</button>
|
||||||
|
</div>
|
||||||
|
{patterns.length > 0 ? (
|
||||||
|
<ul className="schedule-card schedule-list" aria-label="Presence patterns">
|
||||||
|
{patterns.map((pattern) => (
|
||||||
|
<PatternRow
|
||||||
|
key={pattern.id}
|
||||||
|
pattern={pattern}
|
||||||
|
deletion={patternDeletion}
|
||||||
|
onEdit={() => setEditor({ kind: "pattern", pattern })}
|
||||||
|
onAskDelete={() => setPatternDeletion({ kind: "confirming", id: pattern.id })}
|
||||||
|
onKeep={() => setPatternDeletion({ kind: "idle" })}
|
||||||
|
onDelete={() => confirmPatternDelete(pattern.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="schedule-card schedule-empty" data-testid="patterns-empty">
|
||||||
|
No patterns for {child.display_name} — home every day.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{patternDeletion.kind === "error" ? (
|
||||||
|
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
||||||
|
{patternDeletion.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="schedule-section-head">
|
<div className="schedule-section-head">
|
||||||
<h3 className="schedule-section-label">Overrides</h3>
|
<h3 className="schedule-section-label">Overrides</h3>
|
||||||
@@ -512,7 +540,7 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
|
|||||||
ref={addRef}
|
ref={addRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="schedule-text-action"
|
className="schedule-text-action"
|
||||||
onClick={() => setEditorOpen(true)}
|
onClick={() => setEditor({ kind: "override" })}
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,23 +1,44 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
cycleLabel,
|
||||||
cycleWeekIndex,
|
cycleWeekIndex,
|
||||||
dayState,
|
dayState,
|
||||||
|
emptyPattern,
|
||||||
monthDates,
|
monthDates,
|
||||||
monthStart,
|
monthStart,
|
||||||
presenceRule,
|
patternDaysLabel,
|
||||||
|
patternIncludes,
|
||||||
resizePattern,
|
resizePattern,
|
||||||
togglePatternDay,
|
togglePatternDay,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./cycle";
|
} 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.
|
type KindedCycle = PatternCycle & { kind: PatternKind };
|
||||||
const SCHEDULE: PresenceScheduleBody = {
|
|
||||||
|
// 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,
|
cycle_length_weeks: 2,
|
||||||
anchor_date: "2026-09-07",
|
anchor_date: "2026-09-07",
|
||||||
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
|
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", () => {
|
describe("cycleWeekIndex", () => {
|
||||||
it("follows the anchor across a month, wrapping every 2 weeks", () => {
|
it("follows the anchor across a month, wrapping every 2 weeks", () => {
|
||||||
const expected: Record<string, number> = {};
|
const expected: Record<string, number> = {};
|
||||||
@@ -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 },
|
{ 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", () => {
|
it("a home-only child: overrides first, then absent on days no home pattern covers", () => {
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
|
const patterns = [SCHEDULE];
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
|
expect(dayState(patterns, overrides, "2026-09-08")).toBe("home");
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
|
expect(dayState(patterns, overrides, "2026-09-11")).toBe("override-away");
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
|
expect(dayState(patterns, overrides, "2026-09-15")).toBe("away");
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
|
expect(dayState(patterns, overrides, "2026-09-16")).toBe("override-home");
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
|
expect(dayState(patterns, overrides, "2026-09-19")).toBe("home");
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-05")).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", () => {
|
it("combines the owner's two away patterns; every other day is home", () => {
|
||||||
expect(dayState(null, [], "2026-09-15")).toBe("home");
|
const patterns = [THU_FRI, ALTERNATE_WEEKENDS];
|
||||||
|
const expected: Record<string, string> = {};
|
||||||
|
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", () => {
|
it("toggles one weekday of one cycle week", () => {
|
||||||
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
|
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
|
||||||
0: [0, 1, 2, 3, 4, 5, 6],
|
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]);
|
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, 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", () => {
|
it("describes the cycle and the weekday set in plain language", () => {
|
||||||
expect(presenceRule(null)).toBe("Every day");
|
expect(cycleLabel(1)).toBe("Every week");
|
||||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
|
expect(cycleLabel(2)).toBe("Every 2 weeks");
|
||||||
"Every day",
|
expect(patternDaysLabel(THU_FRI)).toBe("Thu, Fri");
|
||||||
);
|
expect(patternDaysLabel(ALTERNATE_WEEKENDS)).toBe("Week 1: Sat, Sun · Week 2: No days");
|
||||||
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
|
expect(patternDaysLabel(SCHEDULE)).toBe("Week 1: Every day · Week 2: Sat, Sun");
|
||||||
expect(
|
// Listed Monday-first by weekday number, whatever order they were stored in.
|
||||||
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
|
expect(patternDaysLabel({ ...THU_FRI, pattern: { "0": [6, 0] } })).toBe("Mon, Sun");
|
||||||
).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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+58
-37
@@ -10,12 +10,23 @@
|
|||||||
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
|
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
|
||||||
* viewer offset or DST change can shift a day.
|
* 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;
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
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. */
|
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
|
||||||
export function dayNumber(iso: string): number {
|
export function dayNumber(iso: string): number {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||||
@@ -67,44 +78,50 @@ export function monthDates(firstIso: string): string[] {
|
|||||||
return dates;
|
return dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Present every day for every week of the cycle (the no-schedule case). */
|
/** Every week of the cycle covers no day yet (a new pattern). */
|
||||||
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
|
export function emptyPattern(cycleLengthWeeks: number): PatternWeeks {
|
||||||
const pattern: PresencePattern = {};
|
const pattern: PatternWeeks = {};
|
||||||
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
|
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [];
|
||||||
return pattern;
|
return pattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DayState = "home" | "away" | "override-home" | "override-away";
|
export type DayState = "home" | "away" | "override-home" | "override-away";
|
||||||
|
|
||||||
export function patternIncludes(
|
/** Does the pattern cover `date` (its cycle week lists the date's weekday)? */
|
||||||
schedule: PresenceScheduleBody,
|
export function patternIncludes(cycle: PatternCycle, date: string): boolean {
|
||||||
date: string,
|
const week = cycleWeekIndex(cycle.anchor_date, date, cycle.cycle_length_weeks);
|
||||||
): boolean {
|
return (cycle.pattern[week] ?? []).includes(weekdayOf(date));
|
||||||
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;
|
* A date's combined state for one child, mirroring core/presence.py
|
||||||
* otherwise the schedule's pattern decides (no schedule = home every day).
|
* `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(
|
export function dayState(
|
||||||
schedule: PresenceScheduleBody | null,
|
patterns: (PatternCycle & { kind: PatternKind })[],
|
||||||
overrides: PresenceOverride[],
|
overrides: PresenceOverride[],
|
||||||
date: string,
|
date: string,
|
||||||
): DayState {
|
): DayState {
|
||||||
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
|
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
|
||||||
if (override) return override.is_present ? "override-home" : "override-away";
|
if (override) return override.is_present ? "override-home" : "override-away";
|
||||||
if (!schedule) return "home";
|
if (patterns.some((p) => p.kind === "away" && patternIncludes(p, date))) return "away";
|
||||||
return patternIncludes(schedule, date) ? "home" : "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. */
|
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
|
||||||
export function togglePatternDay(
|
export function togglePatternDay(
|
||||||
pattern: PresencePattern,
|
pattern: PatternWeeks,
|
||||||
week: number,
|
week: number,
|
||||||
weekday: number,
|
weekday: number,
|
||||||
): PresencePattern {
|
): PatternWeeks {
|
||||||
const days = pattern[week] ?? [];
|
const days = pattern[week] ?? [];
|
||||||
const next = days.includes(weekday)
|
const next = days.includes(weekday)
|
||||||
? days.filter((d) => d !== weekday)
|
? days.filter((d) => d !== weekday)
|
||||||
@@ -112,31 +129,35 @@ export function togglePatternDay(
|
|||||||
return { ...pattern, [week]: next };
|
return { ...pattern, [week]: next };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fit the pattern to a new cycle length; added weeks start present every day. */
|
/** Fit the pattern to a new cycle length; added weeks start covering no day. */
|
||||||
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
|
export function resizePattern(pattern: PatternWeeks, cycleLengthWeeks: number): PatternWeeks {
|
||||||
const next: PresencePattern = {};
|
const next: PatternWeeks = {};
|
||||||
for (let week = 0; week < cycleLengthWeeks; week += 1) {
|
for (let week = 0; week < cycleLengthWeeks; week += 1) {
|
||||||
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
|
next[week] = pattern[week] ? [...pattern[week]] : [];
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinWeeks(weeks: number[]): string {
|
function daysLabel(days: number[]): string {
|
||||||
if (weeks.length === 1) return String(weeks[0]);
|
if (days.length === 7) return "Every day";
|
||||||
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
|
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 {
|
* Plain-language weekday set: `Thu, Fri` for a 1-week cycle, otherwise one
|
||||||
if (!schedule) return "Every day";
|
* entry per cycle week, e.g. `Week 1: Sat, Sun · Week 2: No days`.
|
||||||
const weeks: number[] = [];
|
*/
|
||||||
let full = true;
|
export function patternDaysLabel(cycle: PatternCycle): string {
|
||||||
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
|
if (cycle.cycle_length_weeks === 1) return daysLabel(cycle.pattern[0] ?? []);
|
||||||
const days = schedule.pattern[week] ?? [];
|
const weeks: string[] = [];
|
||||||
if (days.length > 0) weeks.push(week + 1);
|
for (let week = 0; week < cycle.cycle_length_weeks; week += 1) {
|
||||||
if (days.length < 7) full = false;
|
weeks.push(`Week ${week + 1}: ${daysLabel(cycle.pattern[week] ?? [])}`);
|
||||||
}
|
}
|
||||||
if (full) return "Every day";
|
return weeks.join(" · ");
|
||||||
if (weeks.length === 0) return "Never home";
|
}
|
||||||
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
|
|
||||||
|
/** `Every week`, `Every 2 weeks`. */
|
||||||
|
export function cycleLabel(cycleLengthWeeks: number): string {
|
||||||
|
return cycleLengthWeeks === 1 ? "Every week" : `Every ${cycleLengthWeeks} weeks`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PencilGlyph({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||||
|
<path d="m15 5 4 4" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Trash2Glyph({ size = 16 }: { size?: number }) {
|
export function Trash2Glyph({ size = 16 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<Glyph size={size}>
|
<Glyph size={size}>
|
||||||
|
|||||||
Reference in New Issue
Block a user