From e5072ba99edb5b8269dc55ea07070d7aaa983e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 00:21:39 +0200 Subject: [PATCH] fix(reminders): bound a rolling reminder's re-anchor by the notify-hour slot's own day A rolling reminder at the ten-year maximum interval lost its next due when the satisfy instant fell in the late-UTC evening. The next-occurrence search bounded the single deterministic rolling slot by the end of the anchor's UTC day, but the applied notify-hour slot lands on the following UTC day whenever the anchor sits in the evening (the local morning is the next calendar day in a positive-offset zone). The anchor-day ceiling was then a day short and clipped the very slot it meant to admit, so logging the next dose set nextDueAt to null and the booster silently disappeared. Key the ceiling off the applied slot instant instead; endsOn stays enforced inside expandRolling. --- src/lib/medications/scheduling/recurrence.ts | 32 ++++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index b83bf0b63..71d2717b3 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -317,21 +317,27 @@ export function nextOccurrenceAfter( Math.min(dayFloor.getTime(), rollingDoseDayFloor(schedule, ctx)), ); // 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 + // (or the anchor) at the notify-hour — 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 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. + // should re-anchor it). Bound the search by the deterministic slot's OWN + // end-of-day. The bound must key off the applied notify-hour instant, not + // the raw anchor: when the anchor sits in the late-UTC evening its local + // notify-hour slot falls on the NEXT UTC day, so an anchor-day ceiling is a + // day short and re-clips the very slot it meant to admit. `endsOn` stays + // enforced inside `expandRolling`, so a course-window reminder still + // self-expires. + const rollingSlotInstant = applyTimeOfDayToDate( + rollingDoseAnchor(schedule, ctx), + schedule.timesOfDay[0] ?? schedule.windowStart, + ctx.timeZone, + ); const rollingCeil = new Date( - Math.max( - limit.getTime(), - endOfUtcDay(rollingDoseAnchor(schedule, ctx)).getTime(), - ), + Math.max(limit.getTime(), endOfUtcDay(rollingSlotInstant).getTime()), ); const slots = expandRolling(schedule, ctx, floor, rollingCeil); for (const s of slots) {