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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:42:29 +02:00
1 parent eada82f61e
commit 2c2151ae70
3 files changed
+53 -22

No files matched your search

+11 -8
View File
@@ -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;
@@ -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
+19 -14
View File
@@ -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);
}