Core, API and Admin PWA: multiple presence patterns per child (schema 9) (#231)

This commit was merged in pull request #231.
This commit is contained in:
2026-09-24 20:09:56 -04:00
parent 5650a1f5a2
commit 88b4733a2d
36 changed files with 4230 additions and 1388 deletions
+104 -28
View File
@@ -1,23 +1,44 @@
import { describe, expect, it } from "vitest";
import {
cycleLabel,
cycleWeekIndex,
dayState,
emptyPattern,
monthDates,
monthStart,
presenceRule,
patternDaysLabel,
patternIncludes,
resizePattern,
togglePatternDay,
weekdayOf,
} from "./cycle";
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
import type { PatternCycle, PatternKind, PresenceOverride } from "../api/presence";
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
const SCHEDULE: PresenceScheduleBody = {
type KindedCycle = PatternCycle & { kind: PatternKind };
// The migrated single schedule: anchor Monday 2026-09-07; week 0 home every
// day, week 1 home Sat/Sun only.
const SCHEDULE: KindedCycle = {
kind: "home",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
};
// The owner scenario: away Thu+Fri every week, away weekends every 2 weeks.
const THU_FRI: KindedCycle = {
kind: "away",
cycle_length_weeks: 1,
anchor_date: "2026-09-07",
pattern: { 0: [3, 4] },
};
const ALTERNATE_WEEKENDS: KindedCycle = {
kind: "away",
cycle_length_weeks: 2,
anchor_date: "2026-09-07",
pattern: { 0: [5, 6], 1: [] },
};
describe("cycleWeekIndex", () => {
it("follows the anchor across a month, wrapping every 2 weeks", () => {
const expected: Record<string, number> = {};
@@ -58,6 +79,24 @@ describe("calendar helpers", () => {
expect(weekdayOf("2026-09-06")).toBe(6);
});
it("weekdayOf keeps the stored Monday=0 .. Sunday=6 numbering (display order is separate)", () => {
// A full known week plus dates across month and year ends.
expect(
[
"2026-09-07",
"2026-09-08",
"2026-09-09",
"2026-09-10",
"2026-09-11",
"2026-09-12",
"2026-09-13",
].map(weekdayOf),
).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(weekdayOf("2026-11-01")).toBe(6); // Sunday
expect(weekdayOf("2027-01-01")).toBe(4); // Friday
expect(weekdayOf("2024-02-29")).toBe(3); // Thursday
});
it("monthStart shifts across year ends", () => {
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
@@ -71,22 +110,61 @@ describe("dayState", () => {
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
];
it("applies overrides first, then the anchor-based pattern", () => {
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
it("a home-only child: overrides first, then absent on days no home pattern covers", () => {
const patterns = [SCHEDULE];
expect(dayState(patterns, overrides, "2026-09-08")).toBe("home");
expect(dayState(patterns, overrides, "2026-09-11")).toBe("override-away");
expect(dayState(patterns, overrides, "2026-09-15")).toBe("away");
expect(dayState(patterns, overrides, "2026-09-16")).toBe("override-home");
expect(dayState(patterns, overrides, "2026-09-19")).toBe("home");
expect(dayState(patterns, overrides, "2026-09-01")).toBe("away");
expect(dayState(patterns, overrides, "2026-09-05")).toBe("home");
});
it("treats a child with no schedule as home every day", () => {
expect(dayState(null, [], "2026-09-15")).toBe("home");
it("combines the owner's two away patterns; every other day is home", () => {
const patterns = [THU_FRI, ALTERNATE_WEEKENDS];
const expected: Record<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", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
0: [0, 1, 2, 3, 4, 5, 6],
@@ -95,21 +173,19 @@ describe("pattern editing and rule text", () => {
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
});
it("resizes the pattern to the cycle length", () => {
it("resizes the pattern to the cycle length; added weeks cover no day", () => {
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([]);
expect(emptyPattern(2)).toEqual({ 0: [], 1: [] });
});
it("describes the presence rule in plain language", () => {
expect(presenceRule(null)).toBe("Every day");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
"Every day",
);
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
expect(
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
).toBe("Weeks 1 & 3 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
it("describes the cycle and the weekday set in plain language", () => {
expect(cycleLabel(1)).toBe("Every week");
expect(cycleLabel(2)).toBe("Every 2 weeks");
expect(patternDaysLabel(THU_FRI)).toBe("Thu, Fri");
expect(patternDaysLabel(ALTERNATE_WEEKENDS)).toBe("Week 1: Sat, Sun · Week 2: No days");
expect(patternDaysLabel(SCHEDULE)).toBe("Week 1: Every day · Week 2: Sat, Sun");
// Listed Monday-first by weekday number, whatever order they were stored in.
expect(patternDaysLabel({ ...THU_FRI, pattern: { "0": [6, 0] } })).toBe("Mon, Sun");
});
});