mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user