From fef88787e9569c47919cd9c08c6947505235e630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 20:59:35 +0200 Subject: [PATCH] fix(medications): surface a multi-day-overdue first rolling dose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rolling cadence with no intake logged anchors its first dose at startsOn ?? createdAt. When that anchor falls on a prior calendar day the dose is overdue by a day or more, but nextOccurrenceAfter floored the search window to the start of the user's current day and dropped it — so the dashboard showed no next-due and the reminder worker, which shares this engine, emitted nothing. Clamp the floor to the first-dose day in the no-intake branch so a multi-day-overdue start dose surfaces as overdue. The post-intake re-anchor (lastIntakeAt + N) is unaffected. --- .../scheduling/__tests__/recurrence.test.ts | 32 +++++++++++++++++++ src/lib/medications/scheduling/recurrence.ts | 29 +++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index daaa9fa2b..f81918b27 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -392,6 +392,38 @@ describe("occurrencesBetween — rolling", () => { ).toBe("2026-06-10"); }); + it("nextOccurrenceAfter surfaces a multi-day-overdue first dose (startsOn days ago, no intake)", () => { + // v1.8.5 — a rolling course whose startsOn is several calendar days in + // the past with no intake logged must still surface the start dose as + // OVERDUE ("take now"). Flooring the search window only to the start of + // the user's current day dropped the dose out of existence, returning + // null — no next-due AND no reminder. The floor must reach back to the + // first-dose instant in the no-intake case. + const startsOn = d("2026-06-07T06:00:00Z"); // 3 days before NOW + const NOW = d("2026-06-10T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: null, + medication: { startsOn }, + }); + const next = nextOccurrenceAfter(schedule, NOW, ctx); + expect(next).not.toBeNull(); + // The surfaced slot is the start dose (08:00 Berlin on the startsOn day), + // overdue by ~3 days relative to NOW. + expect(next!.at.getTime()).toBeLessThan(NOW.getTime()); + expect( + new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(next!.at), + ).toBe("2026-06-07"); + }); + it("nextOccurrenceAfter returns the future first dose AT startsOn (no intake) without adding N", () => { const NOW = d("2026-06-10T12:00:00Z"); const startsOn = d("2026-06-12T00:00:00Z"); // 2 days out diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 8b59e1bd5..c0db43330 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -259,9 +259,21 @@ export function nextOccurrenceAfter( // of its due day instead of silently rolling it forward by N. if (schedule.rollingIntervalDays !== null) { const dayFloor = startOfDayInTz(after, ctx.timeZone); - const slots = expandRolling(schedule, ctx, dayFloor, limit); + // With no intake logged the first dose anchors at `startsOn ?? createdAt`. + // When that anchor is on a PRIOR calendar day the dose is overdue by ≥1 + // day and must still surface ("take now"); flooring only to the start of + // the user's current day dropped it out of existence (null next-due AND + // no reminder). Reach the floor back to the first-dose instant in that + // case so a multi-day-overdue start dose is returned. After an intake the + // re-anchor (`lastIntakeAt + N`) is always in the future, so the dayFloor + // is the correct, tighter bound there. + const floor = + ctx.lastIntakeAt === null + ? new Date(Math.min(dayFloor.getTime(), firstRollingDoseDayFloor(ctx))) + : dayFloor; + const slots = expandRolling(schedule, ctx, floor, limit); for (const s of slots) { - if (s.at.getTime() < dayFloor.getTime()) continue; + if (s.at.getTime() < floor.getTime()) continue; if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue; return s; } @@ -367,6 +379,19 @@ function expandOneShot( * historical intake events, not "expected slots" the schedule * predicts forward. */ +/** + * Start-of-day (in the user's timezone) of the no-intake first rolling dose + * anchor — `startsOn ?? createdAt`. Used by `nextOccurrenceAfter` to clamp the + * search floor so a multi-day-overdue first dose still surfaces. Mirrors the + * anchor `expandRolling` emits in the `lastIntakeAt === null` branch; flooring + * to the anchor's start-of-day keeps the time-of-day slot (e.g. 08:00) within + * the window rather than ahead of a raw-instant floor. + */ +function firstRollingDoseDayFloor(ctx: RecurrenceContext): number { + const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; + return startOfDayInTz(anchor, ctx.timeZone).getTime(); +} + function expandRolling( schedule: CanonicalSchedule, ctx: RecurrenceContext,