From 47668285559eb6002e22f3cca497ff0f543ed166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 23:54:44 +0200 Subject: [PATCH] fix(reminders): keep a maximum-interval rolling reminder's next due after satisfy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rolling reminder at the engine's ten-year maximum interval lost its next due date the moment its cadence was satisfied. The rolling branch of nextOccurrenceAfter bounded its search by the 10-year anti-spin hardCap, and a satisfied reminder computes its next slot with after === lastSatisfiedAt, so the single deterministic slot lands exactly after + 3650 days — equal to the hardCap to the millisecond. The notify-hour slot then spilled a few hours past the raw boundary and was clipped to nothing, leaving nextDueAt null. The rolling slot is one deterministic instant, not an open-ended walk, so the anti-spin cap must never gate it. Bound the rolling search by that slot's own day instead; endsOn stays enforced inside expandRolling, so a course-window reminder still self-expires. This surfaced on a ten-year tetanus booster: logging the next dose silently dropped the reminder instead of rolling it forward a decade. --- .../__tests__/scheduling.test.ts | 26 +++++++++++++++++++ src/lib/medications/scheduling/recurrence.ts | 19 +++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts index c775f4520..78c85990f 100644 --- a/src/lib/measurement-reminders/__tests__/scheduling.test.ts +++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts @@ -128,6 +128,32 @@ describe("computeReminderNextDueAt", () => { // 19:00 Berlin == 17:00Z (DST). expect(evening!.toISOString()).toBe("2026-06-10T17:00:00.000Z"); }); + + // A ten-year booster (tetanus, FSME, …) uses the engine's maximum rolling + // interval — 3650 days. When such a reminder is satisfied, the satisfy path + // computes the next due with `after === lastSatisfiedAt`, so the single + // rolling slot lands exactly `intervalDays` out — right on the engine's + // ten-year forward search horizon. The boundary slot must still be found; + // a decade booster whose next dose is logged must roll forward, not vanish. + it("rolling at the maximum interval (3650 days) still re-anchors on satisfy", () => { + // Mirrors `satisfyReminder`: lastSatisfiedAt and `after` are the same + // instant (the dose's day-granular occurredAt, at day start). + const satisfiedAt = new Date("2026-08-08T00:00:00Z"); + const reminder = base({ + intervalDays: 3650, + // Anchor from the original 2016 dose + interval — irrelevant once + // satisfied, but this is what the mint wrote. + anchorDate: new Date("2026-03-13T00:00:00Z"), + lastSatisfiedAt: satisfiedAt, + notifyHour: 9, + createdAt: new Date("2026-08-08T00:00:00Z"), + }); + const due = computeReminderNextDueAt(reminder, TZ, satisfiedAt); + expect(due).not.toBeNull(); + // 3650 days after 2026-08-08 → 2036-08-05, slot at 09:00 Berlin (07:00Z). + expect(due!.getUTCFullYear()).toBe(2036); + expect(due!.getTime()).toBeGreaterThan(satisfiedAt.getTime()); + }); }); describe("byHourTimesOfDay", () => { diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index f8735213d..b83bf0b63 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -316,7 +316,24 @@ export function nextOccurrenceAfter( const floor = new Date( Math.min(dayFloor.getTime(), rollingDoseDayFloor(schedule, ctx)), ); - const slots = expandRolling(schedule, ctx, floor, limit); + // The rolling slot is a single deterministic instant — `lastIntakeAt + N` + // (or the anchor) — not an open-ended walk, so the 10-year anti-spin + // `hardCap` must never gate it. It collides at the fencepost: a reminder at + // the maximum ten-year rolling interval, satisfied so that `after === + // lastIntakeAt`, produces a next slot exactly `after + 3650 days` — equal + // to `hardCap` to the millisecond, so the notify-hour slot spills a few + // hours past the raw boundary and `expandRolling` clips it to nothing + // (a ten-year booster silently lost its next due on the very dose that + // should re-anchor it). Bound the search by the deterministic slot's own + // day instead; `endsOn` stays enforced inside `expandRolling`, so a + // course-window reminder still self-expires. + const rollingCeil = new Date( + Math.max( + limit.getTime(), + endOfUtcDay(rollingDoseAnchor(schedule, ctx)).getTime(), + ), + ); + const slots = expandRolling(schedule, ctx, floor, rollingCeil); for (const s of slots) { if (s.at.getTime() < floor.getTime()) continue; if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;