From 2c2151ae70725b4c76ce9543653044206b4d07e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:42:29 +0200 Subject: [PATCH] Keep the rolling anchor when a start only moved forward An intake before startsOn stopped anchoring a rolling cadence, which also moved the next dose of a single-course medication whose start had merely been pushed forward a few days. The intake is now ignored only when the dose it implies would fall before the course starts (a new course after a gap); otherwise it anchors as before. --- src/lib/analytics/compliance/cycle.ts | 19 ++++++----- .../scheduling/__tests__/recurrence.test.ts | 23 +++++++++++++ src/lib/medications/scheduling/recurrence.ts | 33 +++++++++++-------- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index 3626eca5d..a47b263bb 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -158,15 +158,18 @@ export function buildCurrentCycle( if (canonical.scheduleType === "PRN") continue; const n = canonical.rollingIntervalDays; if (n !== null && n > 0) { - // v1.40 (#1024) — an intake from a previous course does not anchor the - // current one; its first dose is due on its start day. - const intakeInCourse = - lastIntakeAt !== null && - (ctx.startsOn === null || - userDayKey(lastIntakeAt, ctx.timeZone) >= dayKeyOfDate(ctx.startsOn)); - const anchor = - intakeInCourse && lastIntakeAt !== null + // v1.40 (#1024) — an intake whose next dose would fall before the + // course starts belongs to an earlier course and does not anchor this + // one (the recurrence engine's rule); otherwise it anchors as before. + const fromIntake = + lastIntakeAt !== null ? new Date(lastIntakeAt.getTime() + n * DAY_MS) + : null; + const anchor = + fromIntake !== null && + (ctx.startsOn === null || + userDayKey(fromIntake, ctx.timeZone) >= dayKeyOfDate(ctx.startsOn)) + ? fromIntake : (ctx.startsOn ?? ctx.createdAt); if (ctx.endsOn && anchor.getTime() > ctx.endsOn.getTime()) continue; const graceMs = (canonical.reminderGraceMinutes ?? 60) * 60 * 1000; diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index 51ef9c400..c67ba61f5 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -242,6 +242,29 @@ describe("occurrencesBetween — RRULE MONTHLY / YEARLY", () => { // ──────────────────────────────────────────────────────────────────── describe("occurrencesBetween — rolling", () => { + it("keeps anchoring on an intake when the start was only moved forward a little (v1.40)", () => { + // Weekly. Last dose Jun 8; the start was then moved to Jun 10. The next + // dose stays Jun 15 (last dose + 7), as it was before courses existed. + const NOW = d("2026-06-11T12:00:00Z"); + const schedule = makeSchedule({ + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: d("2026-06-08T06:00:00Z"), + medication: { startsOn: d("2026-06-10T00:00:00Z") }, + }); + const slots = occurrencesBetween( + schedule, + NOW, + new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000), + ctx, + ); + expect(slots.map((o) => o.at.toISOString())).toEqual([ + "2026-06-15T06:00:00.000Z", + ]); + }); + it("does not let an intake from a previous course anchor a new one (v1.40, #1024)", () => { // The previous course's last dose was months ago; the new course starts // in two days. Its first dose is due on its start day, not on diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 887d3ca95..eb92151d3 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -493,23 +493,28 @@ function rollingDoseAnchor( ctx: RecurrenceContext, ): Date { const intervalDays = schedule.rollingIntervalDays ?? 0; - // v1.40 (#1024) — an intake from before the course started (a previous - // course of the same medication) does not anchor this one: the new - // course's first dose is due on its start day, as for a course with no - // intake at all. - const intakeDay = - ctx.lastIntakeAt !== null - ? civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone) - : null; - const anchorsThisCourse = - intakeDay !== null && - (ctx.medication.startsOn === null || - intakeDay.getTime() >= civilDayOfDate(ctx.medication.startsOn).getTime()); // N calendar days after the local day of the last intake, not N × 24 h: // a late-evening dose across a DST change would otherwise slide onto the // wrong day. - return anchorsThisCourse - ? addCivilDays(intakeDay, intervalDays) + const fromIntake = + ctx.lastIntakeAt !== null + ? addCivilDays( + civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone), + intervalDays, + ) + : null; + // v1.40 (#1024) — an intake whose next dose would fall before the course + // starts belongs to an earlier course (a new course after a gap): it does + // not anchor this one, whose first dose is due on its start day. An intake + // whose next dose falls on or after the start (a start moved forward by a + // few days) anchors exactly as before. + const startDay = + ctx.medication.startsOn !== null + ? civilDayOfDate(ctx.medication.startsOn) + : null; + return fromIntake !== null && + (startDay === null || fromIntake.getTime() >= startDay.getTime()) + ? fromIntake : anchorCivilDay(ctx); }