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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 00:42:54 +02:00
1 parent df85fd62c9
commit 4317eb91ca
8 files changed
+170 -12

No files matched your search

@@ -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([]);
});
});
+2
View File
@@ -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",
};
}
+2 -3
View File
@@ -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 };
@@ -55,6 +55,7 @@ function makeSchedule(
interface CtxOverrides {
timeZone?: string;
lastIntakeAt?: Date | null;
rollingAnchor?: RecurrenceContext["rollingAnchor"];
medication?: Partial<RecurrenceContext["medication"]>;
}
@@ -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") },
});
@@ -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);
@@ -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 = {
+10 -9
View File
@@ -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
@@ -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",
};
}