diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts new file mode 100644 index 000000000..c80bcadd2 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -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 { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: d("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +function makeRollingSchedule( + overrides: Partial = {}, +): 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(); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index 9dbfcb921..daaa9fa2b 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -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 + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 6496ad39c..8b59e1bd5 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -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(