mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user