From 4317eb91ca562748aca19c9193556375f2846362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 00:42:54 +0200 Subject: [PATCH] Make the rolling course anchor opt-in The rule that keeps an earlier course's intake from anchoring a rolling cadence applied to every caller unless one opted out, which is how a booster reminder picked it up. It now applies only when the context declares rollingAnchor: "courseStart"; the four medication context builders declare it, and every other caller keeps the previous behaviour, where the last intake always anchors. A guard enumerates every RecurrenceContext builder in src, requires each to be listed as a medication or a non-medication builder, and checks the declaration on both sides. --- .../recurrence-course-anchor-guard.test.ts | 123 ++++++++++++++++++ src/lib/analytics/compliance/adapters.ts | 2 + src/lib/measurement-reminders/scheduling.ts | 5 +- .../scheduling/__tests__/recurrence.test.ts | 25 ++++ src/lib/medications/scheduling/cadence.ts | 4 + src/lib/medications/scheduling/compliance.ts | 2 + src/lib/medications/scheduling/recurrence.ts | 19 +-- .../medications/scheduling/worker-helpers.ts | 2 + 8 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/recurrence-course-anchor-guard.test.ts diff --git a/src/__tests__/recurrence-course-anchor-guard.test.ts b/src/__tests__/recurrence-course-anchor-guard.test.ts new file mode 100644 index 000000000..b6c5c1e69 --- /dev/null +++ b/src/__tests__/recurrence-course-anchor-guard.test.ts @@ -0,0 +1,123 @@ +/** + * v1.40 (#1024) — the course rule for a rolling anchor is opt-in. + * + * An intake whose next rolling dose falls before `startsOn` belongs to an + * earlier medication course and must not anchor the current one. That rule + * is only right where `startsOn` IS a course start. A measurement or booster + * reminder passes its anchor date in the same field, and the rule once made a + * booster fourteen years overdue come due "later today". So the rule applies + * only when the context declares `rollingAnchor: "courseStart"`. + * + * This guard enumerates every place in `src` that builds a + * `RecurrenceContext` literal and holds two lists against it: every + * medication builder declares the course start, every other builder does + * not. A new builder lands in neither list and fails until someone decides + * which it is. The scan asserts a floor on what it found, so a matcher that + * stopped matching fails instead of agreeing with an empty tree. + * + * Limit, stated: a literal passed straight to the engine without the + * `RecurrenceContext` annotation is invisible here. Every medication path + * today goes through one of the annotated builders below. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const SRC = resolve(__dirname, ".."); +const ROOT = resolve(SRC, ".."); + +/** Medication builders: `startsOn` is a course start. */ +const MEDICATION_BUILDERS = [ + "src/lib/analytics/compliance/adapters.ts", + "src/lib/medications/scheduling/cadence.ts", + "src/lib/medications/scheduling/compliance.ts", + "src/lib/medications/scheduling/worker-helpers.ts", +]; + +/** Builders whose `startsOn` is an anchor date, never a course start. */ +const NON_MEDICATION_BUILDERS = ["src/lib/measurement-reminders/scheduling.ts"]; + +const DECLARATION = + /(\)\s*:\s*RecurrenceContext\s*\{|:\s*RecurrenceContext\s*=\s*\{)/g; + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return entry.name === "generated" || entry.name === "__tests__" + ? [] + : sourceFiles(full); + } + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); +} + +/** The body of the object literal (or function body) opened at `open`. */ +function blockAt(text: string, open: number): string { + let depth = 0; + for (let i = open; i < text.length; i += 1) { + if (text[i] === "{") depth += 1; + if (text[i] === "}") { + depth -= 1; + if (depth === 0) return text.slice(open, i + 1); + } + } + return text.slice(open); +} + +interface Builder { + file: string; + declaresCourseStart: boolean; +} + +function builders(): Builder[] { + const out: Builder[] = []; + for (const file of sourceFiles(SRC)) { + const text = readFileSync(file, "utf8"); + for (const m of text.matchAll(DECLARATION)) { + const open = m.index! + m[0].length - 1; + out.push({ + file: relative(ROOT, file), + declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test( + blockAt(text, open), + ), + }); + } + } + return out; +} + +describe("the rolling course anchor is opt-in", () => { + it("finds the builders it judges (no vacuous pass)", () => { + const found = builders(); + expect(found.length).toBeGreaterThanOrEqual(6); + // The matcher sees an annotated literal and a typed return. + expect([ + ...`const c: RecurrenceContext = { a: 1 };\nfunction f(): RecurrenceContext {\n return {};\n}`.matchAll( + DECLARATION, + ), + ]).toHaveLength(2); + }); + + it("puts every builder on exactly one of the two lists", () => { + const files = [...new Set(builders().map((b) => b.file))].sort(); + expect(files).toEqual( + [...MEDICATION_BUILDERS, ...NON_MEDICATION_BUILDERS].sort(), + ); + }); + + it("has every medication builder declare the course start", () => { + const missing = builders().filter( + (b) => MEDICATION_BUILDERS.includes(b.file) && !b.declaresCourseStart, + ); + expect(missing).toEqual([]); + }); + + it("keeps every other builder on the default (the last intake anchors)", () => { + const declaring = builders().filter( + (b) => NON_MEDICATION_BUILDERS.includes(b.file) && b.declaresCourseStart, + ); + expect(declaring).toEqual([]); + }); +}); diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts index ba184dc73..5d0aa907c 100644 --- a/src/lib/analytics/compliance/adapters.ts +++ b/src/lib/analytics/compliance/adapters.ts @@ -70,6 +70,8 @@ export function toRecurrenceCtx( }, timeZone: ctx.timeZone, lastIntakeAt: ctx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; } diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 81aca717c..7304561e1 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -165,10 +165,9 @@ export function buildReminderRecurrence( createdAt: reminder.createdAt, }, timeZone: tz, + // No `rollingAnchor`: the anchor date is not a course start, so the last + // satisfaction always anchors the next due, however long ago it was. lastIntakeAt: reminder.lastSatisfiedAt, - // The anchor date is not a course start: the last satisfaction always - // anchors the next due, however long ago it was. - rollingAnchor: "lastIntake", }; return { schedule, ctx }; diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index c67ba61f5..a2e7fac2f 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -55,6 +55,7 @@ function makeSchedule( interface CtxOverrides { timeZone?: string; lastIntakeAt?: Date | null; + rollingAnchor?: RecurrenceContext["rollingAnchor"]; medication?: Partial; } @@ -242,6 +243,28 @@ describe("occurrencesBetween — RRULE MONTHLY / YEARLY", () => { // ──────────────────────────────────────────────────────────────────── describe("occurrencesBetween — rolling", () => { + it("lets the last satisfaction anchor a context that declares no course (a booster)", () => { + // Ten-year cadence, last dose fourteen years ago, anchor date today: the + // dose stays overdue rather than coming due on the anchor date. + const NOW = d("2026-06-10T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 3650, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: d("2012-06-01T06:00:00Z"), + medication: { startsOn: d("2026-06-10T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + d("2000-01-01T00:00:00Z"), + new Date(NOW.getTime() + 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots).toHaveLength(1); + expect(slots[0].at.getTime()).toBeLessThan(NOW.getTime()); + }); + it("keeps anchoring on an intake when the start was only moved forward a little (v1.40)", () => { // Weekly. Last dose Jun 8; the start was then moved to Jun 10. The next // dose stays Jun 15 (last dose + 7), as it was before courses existed. @@ -251,6 +274,7 @@ describe("occurrencesBetween — rolling", () => { timesOfDay: ["08:00"], }); const ctx = makeCtx({ + rollingAnchor: "courseStart", lastIntakeAt: d("2026-06-08T06:00:00Z"), medication: { startsOn: d("2026-06-10T00:00:00Z") }, }); @@ -275,6 +299,7 @@ describe("occurrencesBetween — rolling", () => { timesOfDay: ["08:00"], }); const ctx = makeCtx({ + rollingAnchor: "courseStart", lastIntakeAt: d("2026-03-05T07:00:00Z"), medication: { startsOn: d("2026-06-12T00:00:00Z") }, }); diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 17e880f95..635018621 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -208,6 +208,8 @@ function toRecurrenceContext( }, timeZone: engineCtx.timeZone, lastIntakeAt: engineCtx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; } @@ -921,6 +923,8 @@ function missedFromLedger( }, timeZone: userTz, lastIntakeAt: engineCtx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { const base = toCanonical(s, i); diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index 283c74a8e..8a8157db6 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -187,6 +187,8 @@ function ledgerChipCounts( }, timeZone: userTz, lastIntakeAt: engineCtx.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => { const base: CanonicalSchedule = { diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 5a8159ff9..31f1b8177 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -149,15 +149,16 @@ export interface RecurrenceContext { */ lastIntakeAt: Date | null; /** - * v1.40 (#1024) — `"lastIntake"` keeps the rolling anchor on the last - * intake whatever `startsOn` says. A medication's `startsOn` is the start - * of its current course, so an intake whose next dose falls before it - * belongs to an earlier course and does not anchor; a measurement or - * booster reminder's `startsOn` is only its anchor date, and a dose - * fourteen years old on a ten-year cadence must stay overdue. Absent = the - * medication rule. + * v1.40 (#1024) — `"courseStart"`: `startsOn` is the start of a medication + * course, so an intake whose next rolling dose would fall before it belongs + * to an earlier course and does not anchor; the course's first dose is due + * on its start day. Only the medication context builders set it (a guard + * test enumerates them). Absent, the last intake always anchors, which is + * what a measurement or booster reminder needs: its `startsOn` is only an + * anchor date, and a dose fourteen years old on a ten-year cadence must + * stay overdue. */ - rollingAnchor?: "lastIntake"; + rollingAnchor?: "courseStart"; } export interface Occurrence { @@ -523,7 +524,7 @@ function rollingDoseAnchor( ? civilDayOfDate(ctx.medication.startsOn) : null; return fromIntake !== null && - (ctx.rollingAnchor === "lastIntake" || + (ctx.rollingAnchor !== "courseStart" || startDay === null || fromIntake.getTime() >= startDay.getTime()) ? fromIntake diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index f4f80748a..7d1a45199 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -154,6 +154,8 @@ export function buildRecurrenceContext(input: { }, timeZone: input.userTz, lastIntakeAt: input.lastIntakeAt, + // v1.40 (#1024) — a medication: `startsOn` is a course start. + rollingAnchor: "courseStart", }; }