fix(medications): surface the first rolling dose at the start date

Rolling schedules anchored next-due on start + N days when no intake had
been logged yet, which skipped the dose due AT the start date and
suppressed its reminder (the worker shares this engine). The +N cadence
now begins only after the first intake is logged: with no intake the
first dose is due at startsOn (or createdAt). A past start date with no
intake surfaces the dose as due/overdue rather than rolling it forward —
nextOccurrenceAfter floors the rolling search to the start of the user's
day so an overdue or due-earlier-today dose stays visible.

The post-intake re-anchor (lastIntakeAt + N) and the endsOn cap are
unchanged; PRN, one-shot, RRULE and legacy cadences are unaffected.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 19:35:11 +02:00
1 parent eea6385bc5
commit b3f61d0c75
3 files changed
+278 -21

No files matched your search

@@ -0,0 +1,120 @@
/**
* v1.8.5 — `computeNextDueAt` tests, focused on the rolling-cadence
* first-dose semantics.
*
* The server computes `nextDueAt` by asking the canonical engine for the
* earliest next slot across a medication's schedules. The medically
* important case: a rolling course whose `startsOn` is earlier today with
* no intake logged must surface its start dose as DUE/OVERDUE ("take
* now"), not roll it forward by N days (which also suppressed its
* reminder, since the worker shares the engine).
*/
import { describe, expect, it } from "vitest";
import { computeNextDueAt } from "../next-due";
import type {
WorkerMedicationRow,
WorkerScheduleRow,
} from "../worker-helpers";
function d(iso: string): Date {
return new Date(iso);
}
function makeMedication(
overrides: Partial<WorkerMedicationRow> = {},
): WorkerMedicationRow {
return {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: d("2026-01-01T00:00:00Z"),
...overrides,
};
}
function makeRollingSchedule(
overrides: Partial<WorkerScheduleRow> = {},
): WorkerScheduleRow {
return {
id: "sched-1",
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: 7,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
...overrides,
};
}
const BERLIN = "Europe/Berlin";
function berlinDay(date: Date): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: BERLIN,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
describe("computeNextDueAt — rolling first dose", () => {
it("surfaces an overdue first dose (past startsOn, no intake) as DUE today", () => {
const startsOn = d("2026-06-10T06:00:00Z"); // 08:00 Berlin slot is past
const now = d("2026-06-10T12:00:00Z"); // noon, after the slot
const next = computeNextDueAt({
medication: makeMedication({ startsOn }),
schedules: [makeRollingSchedule()],
now,
userTz: BERLIN,
lastIntakeAt: null,
});
expect(next).not.toBeNull();
expect(next!.getTime()).toBeLessThan(now.getTime()); // overdue
expect(berlinDay(next!)).toBe("2026-06-10");
});
it("returns the future first dose AT startsOn (no intake) without adding N", () => {
const startsOn = d("2026-06-12T00:00:00Z");
const now = d("2026-06-10T12:00:00Z");
const next = computeNextDueAt({
medication: makeMedication({ startsOn }),
schedules: [makeRollingSchedule()],
now,
userTz: BERLIN,
lastIntakeAt: null,
});
expect(next).not.toBeNull();
expect(berlinDay(next!)).toBe("2026-06-12");
});
it("re-anchors on lastIntakeAt + N once an intake exists", () => {
const now = d("2026-06-10T12:00:00Z");
const next = computeNextDueAt({
medication: makeMedication({ startsOn: d("2026-06-01T00:00:00Z") }),
schedules: [makeRollingSchedule()],
now,
userTz: BERLIN,
lastIntakeAt: d("2026-06-09T08:00:00Z"),
});
expect(next).not.toBeNull();
expect(berlinDay(next!)).toBe("2026-06-16"); // 2026-06-09 + 7d
});
it("returns null when no schedules", () => {
const next = computeNextDueAt({
medication: makeMedication(),
schedules: [],
now: d("2026-06-10T12:00:00Z"),
userTz: BERLIN,
lastIntakeAt: null,
});
expect(next).toBeNull();
});
});
@@ -279,9 +279,12 @@ describe("occurrencesBetween — rolling", () => {
);
});
it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=T-2d emits one at T+5d", () => {
it("rollingIntervalDays=7 + lastIntakeAt=null + future startsOn → first dose due AT startsOn (not startsOn+N)", () => {
// v1.8.5 — with no intake yet the FIRST rolling dose lands ON the
// start date, not start + N. A future startsOn therefore emits its
// slot at startsOn itself.
const NOW = d("2026-06-10T12:00:00Z");
const startsOn = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000);
const startsOn = new Date(NOW.getTime() + 2 * 24 * 60 * 60 * 1000);
const schedule = makeSchedule({
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
@@ -294,7 +297,7 @@ describe("occurrencesBetween — rolling", () => {
const to = new Date(NOW.getTime() + 14 * 24 * 60 * 60 * 1000);
const slots = occurrencesBetween(schedule, from, to, ctx);
expect(slots).toHaveLength(1);
const expectedDay = new Date(startsOn.getTime() + 7 * 24 * 60 * 60 * 1000);
// The slot is the start dose itself (startsOn), NOT startsOn + 7d.
expect(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
@@ -308,10 +311,36 @@ describe("occurrencesBetween — rolling", () => {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(expectedDay),
}).format(startsOn),
);
});
it("rollingIntervalDays=7 + lastIntakeAt=null + startsOn=today → first dose due today", () => {
// Fresh rolling course starting today: the start dose is due today,
// not seven days out.
const startsOn = d("2026-06-10T00:00:00Z");
const schedule = makeSchedule({
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
});
const ctx = makeCtx({
lastIntakeAt: null,
medication: { startsOn },
});
const from = d("2026-06-10T00:00:00Z");
const to = d("2026-06-24T23:59:59Z");
const slots = occurrencesBetween(schedule, from, to, ctx);
expect(slots).toHaveLength(1);
expect(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(slots[0].at),
).toBe("2026-06-10");
});
it("rolling terminates when endsOn falls before next-due", () => {
const NOW = d("2026-06-10T12:00:00Z");
const lastIntake = new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1000);
@@ -332,6 +361,84 @@ describe("occurrencesBetween — rolling", () => {
);
expect(slots).toHaveLength(0);
});
it("nextOccurrenceAfter surfaces an overdue first dose (past startsOn, no intake) as DUE today", () => {
// v1.8.5 — a rolling course whose startsOn is earlier today with no
// intake logged yet must surface the start dose as DUE/OVERDUE, not
// roll it forward by N. The strict `> now` filter previously dropped
// it; the rolling branch now floors to the start of the user's day.
const startsOn = d("2026-06-10T06:00:00Z"); // 08:00 Berlin slot is past
const NOW = d("2026-06-10T12:00:00Z"); // noon — after the 08:00 slot
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 today's start dose (08:00 Berlin), already in
// the past relative to NOW — i.e. overdue, "take 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-10");
});
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
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();
expect(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(next!.at),
).toBe("2026-06-12");
});
it("nextOccurrenceAfter re-anchors on lastIntakeAt + N after an intake", () => {
// The v1.8.4 re-anchor must stay: once an intake exists, the next
// slot is lastIntakeAt + N, NOT the start date.
const NOW = d("2026-06-10T12:00:00Z");
const lastIntake = d("2026-06-09T08:00:00Z");
const schedule = makeSchedule({
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
});
const ctx = makeCtx({
lastIntakeAt: lastIntake,
medication: { startsOn: d("2026-06-01T00:00:00Z") },
});
const next = nextOccurrenceAfter(schedule, NOW, ctx);
expect(next).not.toBeNull();
expect(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(next!.at),
).toBe("2026-06-16"); // 2026-06-09 + 7d
});
});
// ────────────────────────────────────────────────────────────────────
+47 -17
View File
@@ -16,10 +16,11 @@
* 1. **One-shot** (`medication.oneShot === true`) — single slot at
* the anchor date, one per `timesOfDay` entry.
* 2. **Rolling** (`schedule.rollingIntervalDays !== null`) — next
* slot is `lastIntakeAt + N days` (or `startsOn + N days` if
* the user has never logged an intake). Only the immediately
* next slot is emitted; further slots depend on the user
* actually logging the intake.
* slot is `lastIntakeAt + N days`. With no intake yet the FIRST
* dose is due AT `startsOn ?? createdAt` (not `+ N`); the `+ N`
* cadence only begins once the first intake is logged. Only the
* immediately next slot is emitted; further slots depend on the
* user actually logging the intake.
* 3. **RRULE** (`schedule.rrule !== null`) — RFC 5545 expansion
* via the `rrule` npm lib. Day-anchored dates are returned in
* UTC by the library; each `timesOfDay` entry is then applied
@@ -248,15 +249,19 @@ export function nextOccurrenceAfter(
}
// Rolling is also single-slot (only emits the immediately next).
//
// v1.8.5 — floor the search window to the start of the user's current
// day rather than the strict `after` instant. A rolling dose whose slot
// lands earlier today (a past `startsOn` with no intake, or an overdue
// `lastIntakeAt + N`) is DUE/OVERDUE and must surface as "take now",
// not be skipped by a strict `> now` filter. Flooring to start-of-day
// keeps a slot the user has not yet acted on visible through the rest
// of its due day instead of silently rolling it forward by N.
if (schedule.rollingIntervalDays !== null) {
const slots = expandRolling(
schedule,
ctx,
new Date(after.getTime() + 1),
limit,
);
const dayFloor = startOfDayInTz(after, ctx.timeZone);
const slots = expandRolling(schedule, ctx, dayFloor, limit);
for (const s of slots) {
if (s.at.getTime() <= after.getTime()) continue;
if (s.at.getTime() < dayFloor.getTime()) continue;
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
return s;
}
@@ -334,9 +339,21 @@ function expandOneShot(
// ────────────────────────────────────────────────────────────────────
/**
* Rolling cadence — "every N days from last intake". Emits one
* occurrence at `(lastIntakeAt ?? startsOn ?? createdAt) + N days`,
* applied at the first `timesOfDay` entry (or `windowStart`).
* Rolling cadence — "every N days from last intake".
*
* Anchoring (v1.8.5 — medically-correct first dose):
* - After an intake → next slot is `lastIntakeAt + N days` (the
* v1.8.4 re-anchor). Logging a dose pushes the next one out by N.
* - No intake yet → the FIRST dose is due AT `startsOn ?? createdAt`,
* NOT `start + N`. A rolling course's first dose lands on the start
* date; the `+ N` cadence only kicks in once the first intake is
* logged. The pre-v1.8.5 engine emitted `start + N` here, which
* silently skipped the start-date dose (and suppressed its reminder,
* since the worker shares this engine).
*
* A past `startsOn` with no intake therefore surfaces the start dose as
* DUE/OVERDUE ("take now") via `nextOccurrenceAfter`'s rolling branch,
* rather than rolling it forward.
*
* Design choice: only the FIRST time-of-day entry is emitted even
* when multiple are configured. The rolling semantic — "every N days
@@ -358,10 +375,13 @@ function expandRolling(
): Occurrence[] {
const n = schedule.rollingIntervalDays;
if (n === null || n <= 0) return [];
const anchor =
ctx.lastIntakeAt ?? ctx.medication.startsOn ?? ctx.medication.createdAt;
const nextDue = new Date(anchor.getTime() + n * DAY_MS);
// After an intake the next dose is N days out. With no intake yet the
// FIRST dose is the start date itself (no `+ N`) — see the doc comment.
const nextDue =
ctx.lastIntakeAt !== null
? new Date(ctx.lastIntakeAt.getTime() + n * DAY_MS)
: ctx.medication.startsOn ?? ctx.medication.createdAt;
// endsOn cap.
if (
@@ -560,6 +580,16 @@ function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
return guess;
}
/**
* The UTC instant of midnight (00:00 wall-clock) on `instant`'s day in
* the user's IANA timezone. Used by the rolling-cadence next-due search
* to floor the window to the start of the user's current day so an
* overdue / due-earlier-today rolling dose still surfaces.
*/
function startOfDayInTz(instant: Date, tz: string): Date {
return applyTimeOfDayToDate(instant, "00:00", tz);
}
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = wallClockInTz(date, tz);
const asIfUtc = Date.UTC(