fix(medications): surface a multi-day-overdue first rolling dose

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 21:16:56 +02:00
1 parent b5e98533b8
commit fef88787e9
2 files changed
+59 -2

No files matched your search

@@ -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
+27 -2
View File
@@ -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,