mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user