fix(reminders): keep a maximum-interval rolling reminder's next due after satisfy

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 23:54:44 +02:00
1 parent 9f2cbcb32b
commit 4766828555
2 files changed
+44 -1

No files matched your search

@@ -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", () => {
+18 -1
View File
@@ -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;