From 8a03654e8f2c4f370c4a01998379eac1415c59d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:23:47 +0200 Subject: [PATCH 1/4] fix(medications): read schedule days as calendar days, not in the user's zone A medication's startsOn/endsOn are calendar dates stored as UTC midnight, and the rrule library emits its day anchors the same way. The engine read those midnights on the user's wall clock, which west of UTC is the evening before. A FREQ=WEEKLY;BYDAY=MO plan therefore fired on Sundays in New York, a daily course starting on the 14th produced a dose on the 13th, and an evening dose on the last course day was cut off. One-shot doses, the first dose of a rolling cadence and the legacy weekday walk (startsOn floor, endsOn cap, every-N-weeks phase) carried the same shift. The engine now keeps a calendar day as a calendar day end to end: startsOn and rrule anchors are read from their UTC fields, instants (createdAt, intake times) are converted to the local day they fall on, and only then is the HH:mm materialised in the user's zone. Rolling cadences count calendar days from the local day of the last intake, so a late-evening dose across a DST change no longer slides a day. A nonexistent local time now always moves forward, including in zones whose clocks skip midnight. RRULE anchors with BYHOUR are collapsed per day so a twice-daily protocol cannot emit duplicate slots. CYCLIC on/off weeks are counted as seven-day blocks from the start day in the local calendar. They used to snap to Sunday-rooted UTC weeks, so a three-weeks-on course starting on a Wednesday lost its first days and the off week began on the wrong day east and west of UTC. Measurement reminders pass their anchor and course end as instants; the adapter now converts them to the user's calendar day before handing them to the engine. --- .../__tests__/scheduling-timezones.test.ts | 77 ++++ src/lib/measurement-reminders/scheduling.ts | 26 +- .../__tests__/recurrence-timezones.test.ts | 364 ++++++++++++++++++ src/lib/medications/scheduling/recurrence.ts | 295 +++++++++----- 4 files changed, 666 insertions(+), 96 deletions(-) create mode 100644 src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts create mode 100644 src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts diff --git a/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts new file mode 100644 index 000000000..560d08bbc --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/scheduling-timezones.test.ts @@ -0,0 +1,77 @@ +/** + * A reminder's `anchorDate` / `endsOn` are instants (the form sends local + * midnight), while the recurrence engine takes calendar dates. The adapter + * must hand over the day the instant falls on in the user's zone: east of + * UTC local midnight is still the previous UTC day, and a weekly plan + * anchored there must not start a day early. + */ +import { describe, expect, it } from "vitest"; + +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; + +import { computeReminderNextDueAt } from "../scheduling"; + +const ZONES = [ + "America/New_York", + "America/Los_Angeles", + "America/Santiago", + "Europe/Berlin", + "Asia/Tokyo", + "Pacific/Auckland", + "Pacific/Tongatapu", +] as const; + +function localMidnight(tz: string, y: number, m: number, d: number): Date { + return zonedWallClockToUtc( + { year: y, month: m, day: d, hour: 0, minute: 0 }, + tz, + ); +} + +function localLabel(at: Date | null, tz: string): string | null { + if (!at) return null; + const p = wallClockInTz(at, tz); + return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")} w${p.weekday}`; +} + +describe.each(ZONES)("reminder anchors in %s", (tz) => { + it("a daily RRULE starts on the local anchor day", () => { + const anchor = localMidnight(tz, 2026, 9, 14); + const next = computeReminderNextDueAt( + { + intervalDays: null, + rrule: "FREQ=DAILY", + anchorDate: anchor, + notifyHour: 8, + lastSatisfiedAt: null, + createdAt: new Date("2026-09-01T12:00:00Z"), + }, + tz, + new Date("2026-09-10T12:00:00Z"), + ); + expect(localLabel(next, tz)).toBe("2026-09-14 08:00 w1"); + }); + + it("a course that ends on a local day keeps that day's evening slot", () => { + const next = computeReminderNextDueAt( + { + intervalDays: null, + rrule: "FREQ=DAILY;BYHOUR=7,19", + anchorDate: localMidnight(tz, 2026, 9, 14), + endsOn: zonedWallClockToUtc( + { year: 2026, month: 9, day: 20, hour: 10, minute: 0 }, + tz, + ), + notifyHour: 7, + lastSatisfiedAt: null, + createdAt: new Date("2026-09-01T12:00:00Z"), + }, + tz, + zonedWallClockToUtc( + { year: 2026, month: 9, day: 20, hour: 8, minute: 0 }, + tz, + ), + ); + expect(localLabel(next, tz)).toBe("2026-09-20 19:00 w0"); + }); +}); diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index abb58ba5d..11ea9440b 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -29,6 +29,7 @@ import { type RecurrenceContext, nextOccurrenceAfter, } from "@/lib/medications/scheduling/recurrence"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; /** * The reminder fields this module reads. A subset of the Prisma row so @@ -50,6 +51,12 @@ export interface ReminderScheduleInput { endsOn?: Date | null; } +/** The calendar day `instant` falls on in `tz`, as UTC midnight of that date. */ +function calendarDayInZone(instant: Date, tz: string): Date { + const p = wallClockInTz(instant, tz); + return new Date(Date.UTC(p.year, p.month - 1, p.day)); +} + function hourToHhmm(hour: number): string { const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9; return `${safe.toString().padStart(2, "0")}:00`; @@ -120,17 +127,30 @@ export function buildReminderRecurrence( // First-due anchor when never satisfied: anchorDate ?? createdAt. After // a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we // feed `lastSatisfiedAt` through `lastIntakeAt`. - const startsOn = reminder.anchorDate ?? reminder.createdAt; + // + // The engine's `startsOn` / `endsOn` are calendar dates (the medication + // columns are `@db.Date`, UTC midnight of the date). A reminder's + // `anchorDate` / `endsOn` are instants instead (the form sends local + // midnight, a Coach course sends `now + days`), so hand the engine the + // calendar day each instant falls on in the user's zone. + const tz = timeZone || "Europe/Berlin"; + const startsOn = calendarDayInZone( + reminder.anchorDate ?? reminder.createdAt, + tz, + ); + const endsOn = reminder.endsOn + ? calendarDayInZone(reminder.endsOn, tz) + : null; const ctx: RecurrenceContext = { medication: { id: "measurement-reminder", startsOn, - endsOn: reminder.endsOn ?? null, + endsOn, oneShot: false, createdAt: reminder.createdAt, }, - timeZone: timeZone || "Europe/Berlin", + timeZone: tz, lastIntakeAt: reminder.lastSatisfiedAt, }; diff --git a/src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts b/src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts new file mode 100644 index 000000000..c20840400 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/recurrence-timezones.test.ts @@ -0,0 +1,364 @@ +/** + * Calendar-day correctness of the recurrence engine across time zones. + * + * `Medication.startsOn` / `endsOn` are `@db.Date` columns: a calendar date + * carried as UTC midnight. The engine used to read that midnight in the + * user's zone, which west of UTC is the previous evening, so every + * calendar-anchored cadence landed one day early there: a Monday plan fired + * on Sundays in New York, a course starting on the 14th produced a dose on + * the 13th, and a dose on the last course day in the evening vanished. + * + * Every assertion here is read back on the user's wall clock, and the whole + * matrix runs under several HOST zones as well (the engine must not depend + * on the machine it runs on). The host zone is switched through + * `process.env.TZ` inside the test, because a `TZ=` prefix on the command + * line is overridden by the vitest config. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { wallClockInTz } from "@/lib/tz/wall-clock"; + +import { + advanceRollingOccurrence, + expandRollingRetrospective, + nextOccurrenceAfter, + occurrencesBetween, + type CanonicalSchedule, + type RecurrenceContext, +} from "../recurrence"; + +const USER_ZONES = [ + "America/New_York", + "America/Los_Angeles", + "America/Santiago", + "Europe/Berlin", + "Asia/Tokyo", + "Pacific/Auckland", + "Pacific/Tongatapu", + "UTC", +] as const; + +const HOST_ZONES = ["UTC", "America/Los_Angeles", "Pacific/Auckland"] as const; + +function makeSchedule( + overrides: Partial = {}, +): CanonicalSchedule { + return { + id: "sched-tz", + rrule: null, + rollingIntervalDays: null, + timesOfDay: ["09:00"], + daysOfWeek: null, + windowStart: "09:00", + windowEnd: "10:00", + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...overrides, + }; +} + +function makeCtx( + timeZone: string, + medication: Partial = {}, + lastIntakeAt: Date | null = null, +): RecurrenceContext { + return { + timeZone, + lastIntakeAt, + medication: { + id: "med-tz", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T12:00:00Z"), + ...medication, + }, + }; +} + +/** A calendar date the way Prisma hands back a `@db.Date` column. */ +function date(ymd: string): Date { + return new Date(`${ymd}T00:00:00.000Z`); +} + +function localYmd(at: Date, tz: string): string { + const p = wallClockInTz(at, tz); + return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`; +} + +function localHm(at: Date, tz: string): string { + const p = wallClockInTz(at, tz); + return `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`; +} + +const WIDE_FROM = new Date("2026-07-25T00:00:00Z"); +const WIDE_TO = new Date("2026-12-31T00:00:00Z"); + +describe.each(HOST_ZONES)("recurrence engine on a %s host", (hostZone) => { + const originalTz = process.env.TZ; + beforeAll(() => { + process.env.TZ = hostZone; + }); + afterAll(() => { + process.env.TZ = originalTz; + }); + + describe.each(USER_ZONES)("user in %s", (tz) => { + it("FREQ=WEEKLY;BYDAY=MO fires on local Mondays at the local time", () => { + const slots = occurrencesBetween( + makeSchedule({ rrule: "FREQ=WEEKLY;BYDAY=MO" }), + WIDE_FROM, + WIDE_TO, + makeCtx(tz, { startsOn: date("2026-09-01") }), + ); + expect(slots.length).toBeGreaterThan(10); + for (const s of slots) { + expect(wallClockInTz(s.at, tz).weekday).toBe(1); + expect(localHm(s.at, tz)).toBe("09:00"); + } + expect(localYmd(slots[0].at, tz)).toBe("2026-09-07"); + }); + + it("FREQ=DAILY starts on startsOn, never the day before", () => { + const slots = occurrencesBetween( + makeSchedule({ rrule: "FREQ=DAILY", timesOfDay: ["00:30", "23:30"] }), + WIDE_FROM, + new Date("2026-09-20T00:00:00Z"), + makeCtx(tz, { startsOn: date("2026-09-14") }), + ); + expect(localYmd(slots[0].at, tz)).toBe("2026-09-14"); + expect(localHm(slots[0].at, tz)).toBe("00:30"); + // One slot per time per day: no duplicate, no missing day. + const days = slots.map((s) => localYmd(s.at, tz)); + for (const day of ["2026-09-14", "2026-09-15", "2026-09-16"]) { + expect(days.filter((d) => d === day)).toHaveLength(2); + } + expect(days.every((d) => d >= "2026-09-14")).toBe(true); + }); + + it("FREQ=DAILY keeps the evening dose of the last course day", () => { + const ctx = makeCtx(tz, { + startsOn: date("2026-09-14"), + endsOn: date("2026-09-16"), + }); + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00", "23:00"], + }); + const slots = occurrencesBetween(schedule, WIDE_FROM, WIDE_TO, ctx); + expect( + slots.map((s) => `${localYmd(s.at, tz)} ${localHm(s.at, tz)}`), + ).toEqual([ + "2026-09-14 08:00", + "2026-09-14 23:00", + "2026-09-15 08:00", + "2026-09-15 23:00", + "2026-09-16 08:00", + "2026-09-16 23:00", + ]); + // The next-due walk must reach the same last dose. + const lastMorning = slots[4].at; + const next = nextOccurrenceAfter(schedule, lastMorning, ctx); + expect(next?.at.toISOString()).toBe(slots[5].at.toISOString()); + expect(nextOccurrenceAfter(schedule, slots[5].at, ctx)).toBeNull(); + }); + + it("FREQ=MONTHLY;BYMONTHDAY=1 lands on the first of the local month", () => { + const slots = occurrencesBetween( + makeSchedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }), + WIDE_FROM, + WIDE_TO, + makeCtx(tz, { startsOn: date("2026-08-01") }), + ); + expect(slots.map((s) => localYmd(s.at, tz))).toEqual([ + "2026-08-01", + "2026-09-01", + "2026-10-01", + "2026-11-01", + "2026-12-01", + ]); + }); + + it("legacy every-other-Monday honours startsOn and the week phase", () => { + const slots = occurrencesBetween( + makeSchedule({ daysOfWeek: "i2;1" }), + WIDE_FROM, + new Date("2026-10-31T00:00:00Z"), + makeCtx(tz, { startsOn: date("2026-09-14") }), + ); + expect(slots.map((s) => localYmd(s.at, tz))).toEqual([ + "2026-09-14", + "2026-09-28", + "2026-10-12", + "2026-10-26", + ]); + }); + + it("legacy daily walk stops after the evening dose of endsOn", () => { + const slots = occurrencesBetween( + makeSchedule({ timesOfDay: ["21:00"] }), + WIDE_FROM, + WIDE_TO, + makeCtx(tz, { + startsOn: date("2026-09-14"), + endsOn: date("2026-09-15"), + }), + ); + expect(slots.map((s) => localYmd(s.at, tz))).toEqual([ + "2026-09-14", + "2026-09-15", + ]); + }); + + it("a one-shot dose lands on its own date", () => { + const slots = occurrencesBetween( + makeSchedule({ timesOfDay: ["08:00"] }), + WIDE_FROM, + WIDE_TO, + makeCtx(tz, { + startsOn: date("2026-09-14"), + endsOn: date("2026-09-14"), + oneShot: true, + }), + ); + expect( + slots.map((s) => `${localYmd(s.at, tz)} ${localHm(s.at, tz)}`), + ).toEqual(["2026-09-14 08:00"]); + }); + + it("a rolling first dose is due on startsOn", () => { + const schedule = makeSchedule({ rollingIntervalDays: 7 }); + const ctx = makeCtx(tz, { startsOn: date("2026-09-14") }); + const next = nextOccurrenceAfter( + schedule, + new Date("2026-09-01T12:00:00Z"), + ctx, + ); + expect(next && localYmd(next.at, tz)).toBe("2026-09-14"); + expect(next && localHm(next.at, tz)).toBe("09:00"); + }); + + it("a rolling cadence counts calendar days from a late-evening intake", () => { + // 23:30 local on 2026-10-30; +7 calendar days crosses the northern + // fall-back weekend and the southern spring-forward season. + const intake = zoned(tz, 2026, 10, 30, 23, 30); + const schedule = makeSchedule({ rollingIntervalDays: 7 }); + const ctx = makeCtx(tz, { startsOn: date("2026-09-14") }, intake); + const next = nextOccurrenceAfter(schedule, intake, ctx); + expect(next && localYmd(next.at, tz)).toBe("2026-11-06"); + const after = next && advanceRollingOccurrence(schedule, next, ctx); + expect(after && localYmd(after.at, tz)).toBe("2026-11-13"); + }); + + it("a rolling retrospective back-fill keeps calendar days", () => { + const first = zoned(tz, 2026, 9, 1, 23, 30); + const last = zoned(tz, 2026, 9, 22, 23, 30); + const slots = expandRollingRetrospective( + makeSchedule({ rollingIntervalDays: 7 }), + makeCtx(tz, { startsOn: date("2026-09-01") }), + WIDE_FROM, + new Date("2026-09-25T00:00:00Z"), + [first, last], + new Date("2026-09-24T12:00:00Z"), + ); + expect(slots.map((s) => localYmd(s.at, tz))).toEqual([ + "2026-09-01", + "2026-09-08", + "2026-09-15", + "2026-09-22", + ]); + }); + + it("CYCLIC 3 on / 1 off counts whole weeks from startsOn in the local calendar", () => { + // Starts on a Wednesday; the three "on" weeks are 21 consecutive days. + const slots = occurrencesBetween( + makeSchedule({ + rrule: "FREQ=DAILY", + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + timesOfDay: ["23:30"], + }), + WIDE_FROM, + new Date("2026-11-10T00:00:00Z"), + makeCtx(tz, { startsOn: date("2026-09-16") }), + ); + const days = slots.map((s) => localYmd(s.at, tz)); + expect(days[0]).toBe("2026-09-16"); + expect(days).toContain("2026-10-06"); + expect(days).not.toContain("2026-10-07"); + expect(days).not.toContain("2026-10-13"); + expect(days).toContain("2026-10-14"); + expect(days.filter((d) => d < "2026-10-14")).toHaveLength(21); + }); + }); + + describe("DST transition days", () => { + const cases: Array<[string, string, string]> = [ + ["America/New_York", "2026-03-08", "2026-11-01"], + ["America/Los_Angeles", "2026-03-08", "2026-11-01"], + ["Europe/Berlin", "2026-03-29", "2026-10-25"], + ["America/Santiago", "2026-09-06", "2026-04-05"], + ["Pacific/Auckland", "2026-09-27", "2026-04-05"], + ]; + it.each(cases)( + "%s keeps one dose per local day across %s and %s", + (tz, springDay, fallDay) => { + for (const day of [springDay, fallDay]) { + const start = date(day); + const slots = occurrencesBetween( + makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["00:30", "08:00", "23:30"], + }), + new Date(start.getTime() - 3 * 86_400_000), + new Date(start.getTime() + 3 * 86_400_000), + makeCtx(tz, { startsOn: date("2026-01-01") }), + ); + const onDay = slots.filter((s) => localYmd(s.at, tz) === day); + expect(onDay).toHaveLength(3); + expect(localHm(onDay[1].at, tz)).toBe("08:00"); + expect(localHm(onDay[2].at, tz)).toBe("23:30"); + } + }, + ); + + it.each(cases)( + "%s: a rolling cadence across the spring-forward day %s keeps calendar days", + (tz, springDay) => { + const [y, m, d] = springDay.split("-").map(Number); + // 23:30 local three days before the clocks go forward; seven times + // 24 h later is 00:30 on the day after the due day. + const intake = zoned(tz, y, m, d - 3, 23, 30); + const schedule = makeSchedule({ rollingIntervalDays: 7 }); + const ctx = makeCtx(tz, { startsOn: date("2026-01-01") }, intake); + const next = nextOccurrenceAfter(schedule, intake, ctx); + const due = new Date(Date.UTC(y, m - 1, d - 3 + 7)); + expect(next && localYmd(next.at, tz)).toBe( + due.toISOString().slice(0, 10), + ); + }, + ); + }); +}); + +function zoned( + tz: string, + year: number, + month: number, + day: number, + hour: number, + minute: number, +): Date { + // Solve the local wall clock to an instant without the engine under test. + let guess = Date.UTC(year, month - 1, day, hour, minute); + for (let i = 0; i < 3; i++) { + const p = wallClockInTz(new Date(guess), tz); + const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute); + guess += Date.UTC(year, month - 1, day, hour, minute) - asUtc; + } + return new Date(guess); +} diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 71d2717b3..75716adc9 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -225,8 +225,8 @@ export function occurrencesBetween( /** * v1.7.0 — true when `instant` falls in an "on" week of the cyclic - * on/off phase. The anchor is the medication's `startsOn ?? createdAt`, - * snapped to its UTC week start. `phase = weeksFromAnchor mod + * on/off phase. The anchor is the local calendar day of the medication's + * `startsOn ?? createdAt`; a week is seven calendar days from it. `phase = weeksFromAnchor mod * (on + off)`; the slot survives iff `phase < on`. A non-positive or * missing `cyclicOnWeeks` keeps every slot (defensive — the route + Zod * require a positive value for CYCLIC, but the engine never throws on a @@ -243,11 +243,13 @@ function isInCyclicOnWeek( const cycleLen = onWeeks + offWeeks; if (cycleLen <= 0) return true; - const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; - const anchorWeekStart = startOfUtcWeek(anchor).getTime(); - const instantWeekStart = startOfUtcWeek(instant).getTime(); - const weeksFromAnchor = Math.round( - (instantWeekStart - anchorWeekStart) / WEEK_MS, + // Whole weeks counted in the user's calendar from the start day itself: + // "three weeks on" starting on a Wednesday is 21 consecutive days, and the + // slot's day is its local day, not the UTC day its instant falls on. + const anchorDay = anchorCivilDay(ctx).getTime(); + const slotDay = civilDayOfInstant(instant, ctx.timeZone).getTime(); + const weeksFromAnchor = Math.floor( + Math.round((slotDay - anchorDay) / DAY_MS) / 7, ); const phase = ((weeksFromAnchor % cycleLen) + cycleLen) % cycleLen; return phase < onWeeks; @@ -272,10 +274,15 @@ export function nextOccurrenceAfter( // v1.7.0 — PRN never has a next due instant. if (schedule.scheduleType === "PRN") return null; - const endsOn = ctx.medication.endsOn; + const endsOnDay = endsOnCivilDay(ctx); const hardCap = new Date(after.getTime() + 365 * 10 * DAY_MS); - const limit = endsOn - ? new Date(Math.min(endOfUtcDay(endsOn).getTime(), hardCap.getTime())) + const limit = endsOnDay + ? new Date( + Math.min( + endOfCivilDayInstant(endsOnDay, ctx.timeZone).getTime(), + hardCap.getTime(), + ), + ) : hardCap; const cyclic = schedule.scheduleType === "CYCLIC"; @@ -331,7 +338,7 @@ export function nextOccurrenceAfter( // day short and re-clips the very slot it meant to admit. `endsOn` stays // enforced inside `expandRolling`, so a course-window reminder still // self-expires. - const rollingSlotInstant = applyTimeOfDayToDate( + const rollingSlotInstant = applyTimeOfDayToCivilDay( rollingDoseAnchor(schedule, ctx), schedule.timesOfDay[0] ?? schedule.windowStart, ctx.timeZone, @@ -403,11 +410,11 @@ function expandOneShot( from: Date, to: Date, ): Occurrence[] { - const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; + const anchor = anchorCivilDay(ctx); const times = effectiveTimesOfDay(schedule); const slots: Occurrence[] = []; for (const time of times) { - const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone); + const at = applyTimeOfDayToCivilDay(anchor, time, ctx.timeZone); if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) continue; slots.push(buildOccurrence(at, time, schedule)); } @@ -460,17 +467,25 @@ function rollingDoseAnchor( ctx: RecurrenceContext, ): Date { const intervalDays = schedule.rollingIntervalDays ?? 0; + // 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 ctx.lastIntakeAt !== null - ? new Date(ctx.lastIntakeAt.getTime() + intervalDays * DAY_MS) - : (ctx.medication.startsOn ?? ctx.medication.createdAt); + ? addCivilDays( + civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone), + intervalDays, + ) + : anchorCivilDay(ctx); } function rollingDoseDayFloor( schedule: CanonicalSchedule, ctx: RecurrenceContext, ): number { - const anchor = rollingDoseAnchor(schedule, ctx); - return startOfLocalDayInTz(anchor, ctx.timeZone).getTime(); + return startOfCivilDayInstant( + rollingDoseAnchor(schedule, ctx), + ctx.timeZone, + ).getTime(); } function expandRolling( @@ -486,16 +501,14 @@ function expandRolling( // FIRST dose is the start date itself (no `+ N`) — see the doc comment. const nextDue = rollingDoseAnchor(schedule, ctx); - // endsOn cap. - if ( - ctx.medication.endsOn && - nextDue.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime() - ) { + // endsOn cap — both sides are calendar days. + const endsOnDay = endsOnCivilDay(ctx); + if (endsOnDay && nextDue.getTime() > endsOnDay.getTime()) { return []; } const time = schedule.timesOfDay[0] ?? schedule.windowStart; - const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone); + const at = applyTimeOfDayToCivilDay(nextDue, time, ctx.timeZone); if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) return []; return [buildOccurrence(at, time, schedule)]; @@ -515,14 +528,15 @@ export function advanceRollingOccurrence( const n = schedule.rollingIntervalDays; if (n === null || n <= 0) return null; const time = schedule.timesOfDay[0] ?? schedule.windowStart; - const nextDay = new Date(occurrence.at.getTime() + n * DAY_MS); - const at = applyTimeOfDayToDate(nextDay, time, ctx.timeZone); - if ( - ctx.medication.endsOn && - at.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime() - ) { + const nextDay = addCivilDays( + civilDayOfInstant(occurrence.at, ctx.timeZone), + n, + ); + const endsOnDay = endsOnCivilDay(ctx); + if (endsOnDay && nextDay.getTime() > endsOnDay.getTime()) { return null; } + const at = applyTimeOfDayToCivilDay(nextDay, time, ctx.timeZone); return buildOccurrence(at, time, schedule); } @@ -586,8 +600,9 @@ export function expandRollingRetrospective( // Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little // late). Only a gap strictly beyond this synthesizes skipped cycles. const gapToleranceMs = 1.5 * cycleMs; - const endsCap = ctx.medication.endsOn - ? endOfUtcDay(ctx.medication.endsOn).getTime() + const endsOnDay = endsOnCivilDay(ctx); + const endsCap = endsOnDay + ? endOfCivilDayInstant(endsOnDay, ctx.timeZone).getTime() : Infinity; const inWindow = (at: Date): boolean => @@ -616,11 +631,21 @@ export function expandRollingRetrospective( const gap = next.getTime() - prev.getTime(); if (gap <= gapToleranceMs) continue; // Number of whole cycles the user skipped: floor(gap/N) − 1 missed - // cycles sit strictly between the two intakes. - const cyclesInGap = Math.floor(gap / cycleMs); + // cycles sit strictly between the two intakes. Counted in calendar + // days, so a gap spanning a DST change (one hour short) still counts + // its last cycle. + const prevDay = civilDayOfInstant(prev, ctx.timeZone); + const gapDays = Math.round( + (civilDayOfInstant(next, ctx.timeZone).getTime() - prevDay.getTime()) / + DAY_MS, + ); + const cyclesInGap = Math.floor(gapDays / n); for (let k = 1; k < cyclesInGap; k++) { - const anchor = new Date(prev.getTime() + k * cycleMs); - const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone); + const at = applyTimeOfDayToCivilDay( + addCivilDays(prevDay, k * n), + time, + ctx.timeZone, + ); if (inWindow(at)) { slots.push(buildOccurrence(at, time, schedule)); } @@ -639,12 +664,16 @@ export function expandRollingRetrospective( const forwardToleranceMs = cycleMs / 2; const lastInstant = sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt; + const nextDueDay = + lastInstant !== null + ? addCivilDays(civilDayOfInstant(lastInstant, ctx.timeZone), n) + : anchorCivilDay(ctx); const nextDue = lastInstant !== null ? new Date(lastInstant.getTime() + cycleMs) - : (ctx.medication.startsOn ?? ctx.medication.createdAt); + : startOfCivilDayInstant(nextDueDay, ctx.timeZone); if (now.getTime() - nextDue.getTime() > forwardToleranceMs) { - const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone); + const at = applyTimeOfDayToCivilDay(nextDueDay, time, ctx.timeZone); // Dedupe: a forward slot already represented by a logged intake (the // intake re-anchored exactly N days out) must not double-count. const alreadyPresent = slots.some( @@ -671,16 +700,21 @@ function expandRrule( const rruleStr = schedule.rrule; if (!rruleStr) return []; - const dtstart = ctx.medication.startsOn ?? ctx.medication.createdAt; - const dtstartLine = `DTSTART:${formatUtcBasic(startOfUtcDay(dtstart))}`; + // The rrule library works in "floating" UTC: DTSTART and every emitted + // anchor are calendar days carried as UTC midnight. Keep them that way end + // to end — the anchor's day is read from its UTC fields and only then is + // the HH:mm materialised in the user's zone. Reading the anchor in the + // user's zone instead turned Monday 00:00Z into Sunday evening west of UTC. + const dtstartLine = `DTSTART:${formatUtcBasic(anchorCivilDay(ctx))}`; // Skip the engine-side UNTIL suffix when the user's RRULE already // bounds the recurrence with COUNT or UNTIL — RFC 5545 forbids both // (and any two-UNTIL collision), and RRule.fromString throws on the // duplicate, silently collapsing the schedule to zero slots. const userBoundsRecurrence = /(?:^|;)(?:COUNT|UNTIL)=/.test(rruleStr); + const endsOnDay = endsOnCivilDay(ctx); const untilSuffix = - ctx.medication.endsOn && !userBoundsRecurrence - ? `;UNTIL=${formatUtcBasic(endOfUtcDay(ctx.medication.endsOn))}` + endsOnDay && !userBoundsRecurrence + ? `;UNTIL=${formatUtcBasic(endOfUtcDay(endsOnDay))}` : ""; const full = `${dtstartLine}\nRRULE:${rruleStr}${untilSuffix}`; @@ -697,20 +731,25 @@ function expandRrule( // Walk a generously padded day-anchor window so a per-day timesOfDay // expansion still hits the requested [from, to] after the time-of-day - // is applied. The rrule lib's day-anchored dates land at midnight UTC - // of the BYDAY/BYMONTHDAY day; the time-of-day might push back across - // a day boundary in some timezones, so widen the window by 2 days on - // each side. + // is applied: a calendar day spans instants up to 14 h either side of + // its UTC midnight, so widen the window by 2 days on each side. const padded = 2 * DAY_MS; const after = new Date(from.getTime() - padded); const before = new Date(to.getTime() + padded); - const dayAnchors = rule.between(after, before, true); + // An RRULE with BYHOUR (the measurement-reminder protocols) emits several + // anchors per day; the times of day are expanded per calendar day, so + // collapse the anchors to their days first. + const dayKeys = new Set(); + for (const anchor of rule.between(after, before, true)) { + dayKeys.add(startOfUtcDay(anchor).getTime()); + } const times = effectiveTimesOfDay(schedule); const slots: Occurrence[] = []; - for (const anchor of dayAnchors) { + for (const dayKey of dayKeys) { + const day = new Date(dayKey); for (const time of times) { - const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone); + const at = applyTimeOfDayToCivilDay(day, time, ctx.timeZone); if (at.getTime() < from.getTime()) continue; if (at.getTime() > to.getTime()) continue; slots.push(buildOccurrence(at, time, schedule)); @@ -730,17 +769,19 @@ function expandLegacy( to: Date, ): Occurrence[] { const recurrence = parseScheduleRecurrence(schedule.daysOfWeek); - const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; - const anchorWeekStartUtc = startOfUtcWeek(anchor).getTime(); + const anchorWeekStart = startOfCivilWeek(anchorCivilDay(ctx)).getTime(); const times = effectiveTimesOfDay(schedule); + const endsOnDay = endsOnCivilDay(ctx); const slots: Occurrence[] = []; - // Iterate every UTC day in [from-1, to+1] to cover any time-of-day - // that might land in [from, to] after applying the local-tz HH:mm. + // Iterate every calendar day (carried as UTC midnight) in [from-1, to+1] + // to cover any time-of-day that might land in [from, to] once the HH:mm + // is materialised in the user's zone. Every comparison below is between + // calendar days; only `applyTimeOfDayToCivilDay` touches the zone. const start = startOfUtcDay(new Date(from.getTime() - DAY_MS)); const end = startOfUtcDay(new Date(to.getTime() + DAY_MS)); const startsOnFloor = ctx.medication.startsOn - ? startOfUtcDay(ctx.medication.startsOn).getTime() + ? civilDayOfDate(ctx.medication.startsOn).getTime() : null; for ( let day = start; @@ -754,13 +795,12 @@ function expandLegacy( if (startsOnFloor !== null && day.getTime() < startsOnFloor) { continue; } - // Day-of-week filter (empty = every day). Use the user's - // timezone weekday so the legacy "Mon = 1" encoding aligns with - // the user's local-Mon, not UTC-Mon. - const localWeekday = wallClockInTz(day, ctx.timeZone).weekday; + // Day-of-week filter (empty = every day). `day` IS the user's + // calendar day, so its UTC weekday is the local weekday. + const weekday = day.getUTCDay(); if ( recurrence.daysOfWeek.length > 0 && - !recurrence.daysOfWeek.includes(localWeekday) + !recurrence.daysOfWeek.includes(weekday) ) { continue; } @@ -768,9 +808,9 @@ function expandLegacy( // Multi-week interval — the fix vs the legacy worker, which // silently ignored intervalWeeks. if (recurrence.intervalWeeks > 1) { - const dayWeekStart = startOfUtcWeek(day).getTime(); + const dayWeekStart = startOfCivilWeek(day).getTime(); const weeksFromAnchor = Math.round( - (dayWeekStart - anchorWeekStartUtc) / WEEK_MS, + (dayWeekStart - anchorWeekStart) / WEEK_MS, ); const phase = ((weeksFromAnchor % recurrence.intervalWeeks) + @@ -780,15 +820,12 @@ function expandLegacy( } // endsOn cap. - if ( - ctx.medication.endsOn && - day.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime() - ) { + if (endsOnDay && day.getTime() > endsOnDay.getTime()) { continue; } for (const time of times) { - const at = applyTimeOfDayToDate(day, time, ctx.timeZone); + const at = applyTimeOfDayToCivilDay(day, time, ctx.timeZone); if (at.getTime() < from.getTime()) continue; if (at.getTime() > to.getTime()) continue; slots.push(buildOccurrence(at, time, schedule)); @@ -807,37 +844,115 @@ function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] { : [schedule.windowStart]; } +// ──────────────────────────────────────────────────────────────────── +// Calendar days +// +// A "civil day" is a calendar date carried as UTC midnight of its +// Y/M/D, the same shape Prisma returns for a `@db.Date` column and the +// rrule library works in. Its UTC fields ARE the calendar date; it is +// never read in the user's zone. Instants (createdAt, intake times, +// slot instants) become civil days through `civilDayOfInstant`, which +// reads them on the user's wall clock. Only `applyTimeOfDayToCivilDay` +// turns a civil day back into an instant. +// ──────────────────────────────────────────────────────────────────── + +/** A `@db.Date` value (UTC midnight of the calendar date) as a civil day. */ +function civilDayOfDate(d: Date): Date { + return startOfUtcDay(d); +} + +/** The calendar day an instant falls on in `tz`, as a civil day. */ +function civilDayOfInstant(instant: Date, tz: string): Date { + const p = wallClockInTz(instant, tz); + return new Date(Date.UTC(p.year, p.month - 1, p.day)); +} + +function addCivilDays(day: Date, n: number): Date { + return new Date( + Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate() + n), + ); +} + /** - * Apply an "HH:mm" time-of-day to `day` in the user's IANA timezone, - * returning the corresponding UTC instant. DST-aware via the two-pass - * solver pattern from `medication-schedule.ts`. - * - * `day` is interpreted as "the day in the user's timezone at which - * the time-of-day should land" — we read its wall-clock Y/M/D in the - * target zone, then materialise the instant for that Y/M/D + H/M in - * that zone. So passing midnight-UTC on the spring-forward day with - * Europe/Berlin gives the local-Berlin day, not the previous-Berlin - * day even if the UTC midnight technically falls before the local - * day boundary. + * The schedule's first calendar day: `startsOn` is a calendar date; the + * `createdAt` fallback is an instant and so is read on the user's clock. */ -function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date { +function anchorCivilDay(ctx: RecurrenceContext): Date { + return ctx.medication.startsOn + ? civilDayOfDate(ctx.medication.startsOn) + : civilDayOfInstant(ctx.medication.createdAt, ctx.timeZone); +} + +function endsOnCivilDay(ctx: RecurrenceContext): Date | null { + return ctx.medication.endsOn ? civilDayOfDate(ctx.medication.endsOn) : null; +} + +/** Local midnight of a civil day (DST-safe, via its local noon). */ +function startOfCivilDayInstant(day: Date, tz: string): Date { + return startOfLocalDayInTz(applyTimeOfDayToCivilDay(day, "12:00", tz), tz); +} + +/** The last millisecond of a civil day on the user's clock. */ +function endOfCivilDayInstant(day: Date, tz: string): Date { + return new Date( + startOfCivilDayInstant(addCivilDays(day, 1), tz).getTime() - 1, + ); +} + +/** The Sunday that starts the calendar week of a civil day. */ +function startOfCivilWeek(day: Date): Date { + return addCivilDays(day, -day.getUTCDay()); +} + +/** + * Materialise an "HH:mm" on a civil day in the user's IANA timezone, + * returning the corresponding UTC instant. DST-aware via the two-pass + * solver pattern from `medication-schedule.ts`: a wall-clock time that + * does not exist on a spring-forward day resolves forward (02:30 in + * Europe/Berlin on 2026-03-29 becomes 03:30). + * + * The calendar date comes from the civil day's UTC fields and is never + * re-read in `tz` — see the section comment above. + */ +function applyTimeOfDayToCivilDay(day: Date, hhmm: string, tz: string): Date { const [hStr, mStr] = hhmm.split(":"); const h = Number(hStr); const m = Number(mStr); if (!Number.isFinite(h) || !Number.isFinite(m)) return day; - const parts = wallClockInTz(day, tz); - let guess = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0), + const wall = Date.UTC( + day.getUTCFullYear(), + day.getUTCMonth(), + day.getUTCDate(), + h, + m, + 0, + 0, ); - for (let i = 0; i < 2; i++) { - const offsetMin = tzOffsetMinutes(guess, tz); - guess = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0) - - offsetMin * 60_000, - ); + // Try the zone offset in force a day before and a day after. A candidate + // is valid when it reads back as the requested wall clock. On a + // fall-back day both are valid and the earlier one wins; on a + // spring-forward day neither is, and the pre-transition offset moves the + // time forward past the gap. That also covers zones whose clocks skip + // midnight itself (America/Santiago), where an iterative solver settled + // on the previous evening. + const before = new Date( + wall - tzOffsetMinutes(new Date(wall - DAY_MS), tz) * 60_000, + ); + const after = new Date( + wall - tzOffsetMinutes(new Date(wall + DAY_MS), tz) * 60_000, + ); + const readsBack = (c: Date): boolean => { + const p = wallClockInTz(c, tz); + return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) === wall; + }; + const validBefore = readsBack(before); + const validAfter = readsBack(after); + if (validBefore && validAfter) { + return before.getTime() <= after.getTime() ? before : after; } - return guess; + if (validAfter) return after; + return before; } function tzOffsetMinutes(date: Date, tz: string): number { @@ -916,12 +1031,6 @@ function endOfUtcDay(d: Date): Date { ); } -function startOfUtcWeek(d: Date): Date { - const midnight = startOfUtcDay(d); - const weekday = midnight.getUTCDay(); // 0 = Sun - return new Date(midnight.getTime() - weekday * DAY_MS); -} - /** RFC 5545 basic-format UTC instant: `YYYYMMDDTHHMMSSZ`. */ function formatUtcBasic(d: Date): string { const pad = (n: number, w = 2): string => String(n).padStart(w, "0"); From 888b738546f553a8b0c4c87fbd138a88e3ce3509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:27:25 +0200 Subject: [PATCH 2/4] fix(reminders): send a multi-hour protocol at every hour it names A reminder with FREQ=DAILY;BYHOUR=7,19 (the morning-and-evening blood pressure course) was only ever sent in the morning. The tick opened its send window at notifyHour alone, so the 19:00 slot stayed due until 07:00 the next day, and the claim was keyed per local day, so a second send that day would have been refused anyway. The window is now the hour of the current slot: the local hour of nextDueAt when it is one of the rule's BYHOUR values, else notifyHour. The early skip for a reminder already sent for its slot uses the same hour, so it stays consistent with the hold-open rule and lastNotifiedAt. A multi-hour reminder claims per slot hour; a single-hour reminder keeps the per-day key. --- .../measurement-reminder-byhour.test.ts | 119 ++++++++++++++++++ src/lib/jobs/measurement-reminder.ts | 64 ++++++++-- 2 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts diff --git a/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts b/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts new file mode 100644 index 000000000..511d06ff9 --- /dev/null +++ b/src/lib/jobs/__tests__/measurement-reminder-byhour.test.ts @@ -0,0 +1,119 @@ +/** + * A multi-hour protocol (`FREQ=DAILY;BYHOUR=7,19`, the morning-and-evening + * blood pressure course) must be sent at both hours. The tick used to gate + * on `notifyHour` alone and claim one send per local day, so the evening + * slot sat due until the next morning and was never sent in the evening. + * + * Drives the real tick every 15 minutes across two local days in several + * zones, with a claim ledger that behaves like the real one (a key claims + * once), and a Prisma double whose updates land on the row the next tick + * reads. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const claimed = vi.hoisted(() => new Set()); +vi.mock("@/lib/notifications/reminder-dedup", async () => { + const actual = await vi.importActual< + typeof import("@/lib/notifications/reminder-dedup") + >("@/lib/notifications/reminder-dedup"); + return { + ...actual, + claimNotificationEvent: vi.fn( + async (_prisma: unknown, args: { dedupKey: string }) => { + if (claimed.has(args.dedupKey)) return false; + claimed.add(args.dedupKey); + return true; + }, + ), + }; +}); + +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; + +import { runMeasurementReminderTick } from "../measurement-reminder"; + +const ZONES = [ + "Europe/Berlin", + "America/New_York", + "America/Los_Angeles", + "Asia/Tokyo", + "Pacific/Auckland", +] as const; + +beforeEach(() => { + claimed.clear(); +}); + +function at(tz: string, day: number, hour: number, minute = 0): Date { + return zonedWallClockToUtc({ year: 2026, month: 9, day, hour, minute }, tz); +} + +describe.each(ZONES)("BYHOUR=7,19 in %s", (tz) => { + it("sends at 07:00 and at 19:00 on each day", async () => { + const base = { + intervalDays: null, + rrule: "FREQ=DAILY;BYHOUR=7,19", + anchorDate: at(tz, 14, 0), + notifyHour: 7, + lastSatisfiedAt: null, + createdAt: at(tz, 13, 12), + }; + const row: Record = { + id: "r-bp", + userId: "u1", + origin: "COACH", + measurementType: null, + label: "BP", + location: null, + lastSkippedAt: null, + lastNotifiedAt: null, + enabled: true, + endsOn: null, + ...base, + nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)), + user: { id: "u1", timezone: tz, locale: "en" }, + }; + const prisma = { + measurementReminder: { + findMany: vi.fn(async () => + (row.nextDueAt as Date | null) === null ? [] : [row], + ), + update: vi.fn(async ({ data }: { data: Record }) => { + Object.assign(row, data); + return row; + }), + updateMany: vi.fn(async () => ({ count: 0 })), + }, + measurement: { findFirst: vi.fn(async () => null) }, + labResult: { findFirst: vi.fn(async () => null) }, + }; + const sent: string[] = []; + const dispatch = vi.fn(async () => ({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + })); + + for ( + let t = at(tz, 14, 0).getTime(); + t < at(tz, 16, 0).getTime(); + t += 15 * 60_000 + ) { + const now = new Date(t); + const before = dispatch.mock.calls.length; + await runMeasurementReminderTick(prisma as never, now, { + dispatch: dispatch as never, + isModuleEnabled: async () => true, + }); + if (dispatch.mock.calls.length > before) { + const p = wallClockInTz(now, tz); + sent.push( + `${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`, + ); + } + } + + expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]); + }); +}); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 93ca3e0a0..407eb9489 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -50,6 +50,7 @@ import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import type { ModuleKey } from "@/lib/modules/registry"; import { + byHourTimesOfDay, computeReminderNextDueAt, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; @@ -169,12 +170,51 @@ async function cleanupExpiredCoachReminders( } /** - * Pure due-predicate: at this instant, is the reminder due AND inside its - * local notify-hour window? + * The local hours a reminder fires at: every `BYHOUR` of its rule (the + * twice-daily BP protocol `FREQ=DAILY;BYHOUR=7,19`), else its `notifyHour`. + */ +function reminderFireHours(reminder: { + notifyHour: number; + rrule?: string | null; +}): number[] { + const byHour = byHourTimesOfDay(reminder.rrule ?? null); + return byHour + ? byHour.map((hhmm) => Number(hhmm.slice(0, 2))) + : [reminder.notifyHour]; +} + +/** + * The local hour the reminder's current slot belongs to: the hour of + * `nextDueAt` on the user's clock when that is one of its fire hours, else + * `notifyHour` (a slot stamped before the hour was edited, or a test + * fixture with no real slot). For a single-hour reminder this is always + * `notifyHour`; for `BYHOUR=7,19` the evening slot reads 19, so it is sent + * in the evening instead of waiting for the next morning. + */ +export function reminderSlotHour( + reminder: { + nextDueAt: Date | null; + notifyHour: number; + rrule?: string | null; + }, + timezone: string, +): number { + const hours = reminderFireHours(reminder); + if (reminder.nextDueAt !== null && hours.length > 1) { + const slotHour = wallClockInTz(reminder.nextDueAt, timezone).hour; + if (hours.includes(slotHour)) return slotHour; + } + return reminder.notifyHour; +} + +/** + * Pure due-predicate: at this instant, is the reminder due AND inside the + * local hour of its current slot? * * "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps * a reminder that became overdue overnight from firing at 03:00 — it - * waits for the user's chosen `notifyHour` to come round in their local + * waits for its slot's hour (`reminderSlotHour`: the `notifyHour`, or the + * matching `BYHOUR` of a multi-hour rule) to come round in the user's * timezone. Pulled out so the unit tests can pin the window boundary * (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB. */ @@ -183,6 +223,7 @@ export function evaluateMeasurementReminderDue( enabled: boolean; nextDueAt: Date | null; notifyHour: number; + rrule?: string | null; }, timezone: string, now: Date, @@ -190,9 +231,10 @@ export function evaluateMeasurementReminderDue( if (!reminder.enabled || reminder.nextDueAt === null) { return { fire: false, inHourWindow: false, isDue: false }; } + const tz = timezone || "Europe/Berlin"; const isDue = now.getTime() >= reminder.nextDueAt.getTime(); - const parts = wallClockInTz(now, timezone || "Europe/Berlin"); - const inHourWindow = parts.hour === reminder.notifyHour; + const parts = wallClockInTz(now, tz); + const inHourWindow = parts.hour === reminderSlotHour(reminder, tz); return { fire: isDue && inHourWindow, inHourWindow, isDue }; } @@ -298,7 +340,8 @@ export async function runMeasurementReminderTick( reminder.lastNotifiedAt != null && reminder.nextDueAt !== null && reminder.lastNotifiedAt.getTime() >= reminder.nextDueAt.getTime() && - wallClockInTz(now, timezone).hour !== reminder.notifyHour + wallClockInTz(now, timezone).hour !== + reminderSlotHour(reminder, timezone) ) { summary.skippedOutsideWindow += 1; continue; @@ -338,6 +381,7 @@ export async function runMeasurementReminderTick( enabled: reminder.enabled, nextDueAt: reminder.nextDueAt, notifyHour: reminder.notifyHour, + rrule: reminder.rrule, }, timezone, now, @@ -409,10 +453,16 @@ export async function runMeasurementReminderTick( const localDate = new Date(now).toLocaleDateString("sv-SE", { timeZone: timezone, }); + // A multi-hour rule sends more than once a day, so its claim is per + // slot hour; a single-hour reminder keeps the per-day key. + const slotSuffix = + reminderFireHours(reminder).length > 1 + ? `T${String(reminderSlotHour(reminder, timezone)).padStart(2, "0")}` + : ""; const claimed = await claimNotificationEvent(prisma, { recordUserId: reminder.user.id, eventType: "MEASUREMENT_REMINDER", - dedupKey: `measurement:${reminder.id}:${localDate}`, + dedupKey: `measurement:${reminder.id}:${localDate}${slotSuffix}`, since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS), }); if (!claimed) { From 91571d4674e2f9d8499fe3dafdd7f7c7e68cf6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:32:29 +0200 Subject: [PATCH 3/4] fix(medications): count the whole cadence in the supply runway The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever its INTERVAL, so a quarterly injection with two doses left showed about 60 days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused, and a PRN schedule as if it were due every day. The rate now honours INTERVAL for every frequency, counts the listed days and months, scales a cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job selects the schedule type and cycle lengths so it reads the same rate. Whole-dose counts no longer lose one to binary floating point: 2.4 mL at 0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two doses. The stock summary, both runway estimators, the list and detail routes, the GLP-1 route, the Coach snapshot and the inventory readout now floor through one helper that treats anything within 1e-9 of the next whole number as that number. --- src/app/api/medications/[id]/glp1/route.ts | 3 +- src/app/api/medications/[id]/route.ts | 3 +- .../__tests__/supply-runway.test.ts | 75 +++++++++++++++++++ .../medications/detail/supply-runway.ts | 69 +++++++++++++---- .../sections/inventory-dialogs.tsx | 5 +- .../sections/inventory-section.tsx | 9 ++- src/lib/ai/coach/glp1-snapshot.ts | 3 +- src/lib/jobs/medication-low-stock.ts | 5 ++ .../inventory/__tests__/summary.test.ts | 11 +++ src/lib/medications/inventory/summary.ts | 6 +- src/lib/medications/list-read.ts | 3 +- src/lib/medications/units-per-dose.ts | 13 ++++ 12 files changed, 180 insertions(+), 25 deletions(-) diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index bb3f0c689..c8db311ee 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -47,6 +47,7 @@ import { } from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { floorWhole } from "@/lib/medications/units-per-dose"; type RouteParams = { params: Promise<{ id: string }> }; @@ -175,7 +176,7 @@ export const GET = apiHandler( 0, ); const pensRemaining = usable.length; - const dosesRemaining = Math.floor( + const dosesRemaining = floorWhole( unitsRemaining / (Number(medication.unitsPerDose) || 1), ); const weeksOfSupply = dosesRemaining; diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 2d281fbff..ff3a6a91c 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -46,6 +46,7 @@ import { isRecordOnly, scheduleWireFields, } from "@/lib/medications/intake-tracking"; +import { floorWhole } from "@/lib/medications/units-per-dose"; type RouteParams = { params: Promise<{ id: string }> }; @@ -216,7 +217,7 @@ export const GET = apiHandler( const stockDosesRemaining = stockUnitsRemaining === null ? null - : Math.floor( + : floorWhole( stockUnitsRemaining / effectiveUnitsPerDose( schedulesDto, diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index dd16adf8a..90c861570 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -371,3 +371,78 @@ describe("units runway with a mixed units-per-dose value (#1034)", () => { expect(estimateUnitsRunwayDays(10, [morning], 1.2)).toBe(8); }); }); + +describe("estimateDailyDoseCount — cadence parts the rate used to drop", () => { + it("stretches a monthly RRULE by its INTERVAL (every third month)", () => { + const perDay = estimateDailyDoseCount([ + schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }), + ]); + expect(perDay).toBeCloseTo(1 / 90, 10); + // Two doses left last about half a year, not two months. + expect( + estimateRunwayDays(2, [ + schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }), + ]), + ).toBe(180); + }); + + it("counts each BYMONTHDAY of a monthly RRULE", () => { + expect( + estimateDailyDoseCount([ + schedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,15" }), + ]), + ).toBeCloseTo(2 / 30, 10); + }); + + it("stretches a daily RRULE by its INTERVAL (every other day)", () => { + expect( + estimateDailyDoseCount([schedule({ rrule: "FREQ=DAILY;INTERVAL=2" })]), + ).toBeCloseTo(1 / 2, 10); + }); + + it("stretches a yearly RRULE by its INTERVAL and counts BYMONTH", () => { + expect( + estimateDailyDoseCount([ + schedule({ rrule: "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYMONTHDAY=1" }), + ]), + ).toBeCloseTo(1 / 730, 10); + expect( + estimateDailyDoseCount([ + schedule({ rrule: "FREQ=YEARLY;BYMONTH=3,9;BYMONTHDAY=1" }), + ]), + ).toBeCloseTo(2 / 365, 10); + }); + + it("scales a CYCLIC schedule by its on share (3 weeks on, 1 off)", () => { + const cyclic = schedule({ + rrule: "FREQ=DAILY", + scheduleType: "CYCLIC", + cyclicOnWeeks: 3, + cyclicOffWeeks: 1, + }); + expect(estimateDailyDoseCount([cyclic])).toBeCloseTo(3 / 4, 10); + expect(estimateRunwayDays(21, [cyclic])).toBe(28); + }); + + it("counts nothing for an as-needed (PRN) schedule", () => { + expect(estimateDailyDoseCount([schedule({ scheduleType: "PRN" })])).toBe(0); + }); +}); + +describe("whole counts survive binary floating point", () => { + it("2.4 units at 0.8 per dose is three doses", () => { + // 2.4 / 0.8 === 2.9999999999999996 in IEEE 754. + expect(effectiveUnitsPerDose([schedule({})], 0.8)).toBe(0.8); + expect(estimateUnitsRunwayDays(2.4, [schedule({})], 0.8)).toBe(3); + }); + + it("a dose-count runway that divides exactly is not a day short", () => { + // Three doses on Mon/Wed/Fri: 9/7 a day, and 9 / (9/7) is + // 6.999999999999999 in IEEE 754 — a week of supply, not six days. + const s = schedule({ + daysOfWeek: "1,3,5", + timesOfDay: ["08:00", "12:00", "20:00"], + }); + expect(estimateRunwayDays(9, [s])).toBe(7); + }); +}); diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index 77c58b4a5..1041acb0e 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -10,6 +10,7 @@ */ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; +import { floorWhole } from "@/lib/medications/units-per-dose"; /** The schedule fields the runway estimate reads. */ export interface RunwaySchedule { @@ -24,6 +25,30 @@ export interface RunwaySchedule { * `unitsPerDose`, exactly like the consumption path's resolver. */ unitsPerDose?: number | null; + /** + * Schedule type. PRN consumes nothing on a cadence; CYCLIC consumes only + * in its "on" weeks, so its rate is scaled by on / (on + off). + */ + scheduleType?: "SCHEDULED" | "PRN" | "CYCLIC" | string | null; + cyclicOnWeeks?: number | null; + cyclicOffWeeks?: number | null; +} + +/** A positive integer RRULE part (`INTERVAL=3`), or `fallback`. */ +function rrulePositiveInt( + rrule: string, + key: string, + fallback: number, +): number { + const m = new RegExp(`(?:^|;)${key}=(\\d+)(?:;|$)`, "i").exec(rrule); + const n = m ? Number(m[1]) : NaN; + return Number.isInteger(n) && n >= 1 ? n : fallback; +} + +/** How many comma-separated values an RRULE list part carries, or 0. */ +function rruleListCount(rrule: string, key: string): number { + const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule); + return m ? m[1].split(",").filter((v) => v.trim() !== "").length : 0; } /** @@ -32,17 +57,40 @@ export interface RunwaySchedule { * weeks, monthly/yearly RRULEs). */ function scheduleDailyDoseCount(s: RunwaySchedule): number { + if (s.scheduleType === "PRN") return 0; + const rate = cadenceDailyDoseCount(s); + if (s.scheduleType === "CYCLIC") { + // Doses fall only in the "on" weeks of the cycle. + const on = s.cyclicOnWeeks ?? 0; + const off = s.cyclicOffWeeks ?? 0; + if (on > 0 && off > 0) return (rate * on) / (on + off); + } + return rate; +} + +function cadenceDailyDoseCount(s: RunwaySchedule): number { const times = s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1; if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 1) { return times / s.rollingIntervalDays; } const rrule = s.rrule ?? ""; - if (/FREQ=MONTHLY/.test(rrule)) { - return times / 30; + // INTERVAL stretches every calendar frequency: FREQ=MONTHLY;INTERVAL=3 is + // one dose a quarter, not one a month. + const interval = rrulePositiveInt(rrule, "INTERVAL", 1); + if (/FREQ=DAILY/i.test(rrule)) { + return times / interval; } - if (/FREQ=YEARLY/.test(rrule)) { - return times / 365; + if (/FREQ=MONTHLY/i.test(rrule)) { + const perMonth = Math.max( + 1, + rruleListCount(rrule, "BYMONTHDAY") || rruleListCount(rrule, "BYDAY"), + ); + return (times * perMonth) / (30 * interval); + } + if (/FREQ=YEARLY/i.test(rrule)) { + const perYear = Math.max(1, rruleListCount(rrule, "BYMONTH")); + return (times * perYear) / (365 * interval); } // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the // create path stores the cadence on the rrule and leaves daysOfWeek @@ -52,14 +100,7 @@ function scheduleDailyDoseCount(s: RunwaySchedule): number { // daysOfWeek fallback below with daysPerWeek=7, over-estimating the // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. if (/FREQ=WEEKLY/.test(rrule)) { - const byday = /BYDAY=([^;]+)/.exec(rrule); - const bydayCount = - byday && byday[1].length > 0 ? byday[1].split(",").length : 1; - const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); - const interval = - intervalMatch && Number(intervalMatch[1]) >= 1 - ? Number(intervalMatch[1]) - : 1; + const bydayCount = Math.max(1, rruleListCount(rrule, "BYDAY")); return (times * bydayCount) / (7 * interval); } const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); @@ -137,7 +178,7 @@ export function estimateUnitsRunwayDays( medicationUnitsPerDose, ); if (perDayUnits <= 0) return null; - return Math.floor(Math.max(0, unitsRemaining) / perDayUnits); + return floorWhole(Math.max(0, unitsRemaining) / perDayUnits); } /** @@ -151,7 +192,7 @@ export function estimateRunwayDays( if (dosesRemaining <= 0) return null; const perDay = estimateDailyDoseCount(schedules); if (perDay <= 0) return null; - return Math.floor(dosesRemaining / perDay); + return floorWhole(dosesRemaining / perDay); } /** diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx index c2f95cec8..73939f220 100644 --- a/src/components/medications/sections/inventory-dialogs.tsx +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { + floorWhole, formatUnitsPerDose, parseUnitsPerDoseInput, } from "@/lib/medications/units-per-dose"; @@ -267,10 +268,10 @@ export function AddInventoryDialog({ ? t("medications.detail.bestand.quantityInUnits", { units }) : // A unit count below one dose must not read "≈ 0 doses" — // it is simply less than one dose. - Math.floor(parsed / unitsPerDose) === 0 + floorWhole(parsed / unitsPerDose) === 0 ? t("medications.detail.bestand.quantityUnderOneDose") : t("medications.detail.bestand.quantityInDoses", { - doses: Math.floor(parsed / unitsPerDose), + doses: floorWhole(parsed / unitsPerDose), })}

)} diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index e6128df32..44750aa0d 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -60,7 +60,10 @@ import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/date-format"; import { queryKeys } from "@/lib/query-keys"; import { formatUnitCount } from "@/components/medications/units-per-dose"; -import { formatUnitsPerDose } from "@/lib/medications/units-per-dose"; +import { + floorWhole, + formatUnitsPerDose, +} from "@/lib/medications/units-per-dose"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import type { SupplySummary } from "@/lib/medications/inventory/summary"; import { @@ -382,8 +385,8 @@ export function InventorySection({ {item.unitsRemaining == null || item.unitsTotal == null ? t("medications.detail.bestand.unknown") : t("medications.detail.bestand.doses", { - remaining: Math.floor(item.unitsRemaining / perDose), - total: Math.floor(item.unitsTotal / perDose), + remaining: floorWhole(item.unitsRemaining / perDose), + total: floorWhole(item.unitsTotal / perDose), })} {perDose !== 1 && item.unitsRemaining != null && diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index c452eaa8e..0a17ba5ca 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -24,6 +24,7 @@ import { readNote } from "@/lib/crypto/note-cipher"; import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match"; import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags"; import { isRecordOnly } from "@/lib/medications/intake-tracking"; +import { floorWhole } from "@/lib/medications/units-per-dose"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -372,7 +373,7 @@ export async function buildGlp1SnapshotBlock( 0, ); pensRemaining = usable.length; - dosesRemaining = Math.floor( + dosesRemaining = floorWhole( unitsRemaining / (Number(med.unitsPerDose) || 1), ); weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek); diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 9990943fc..86df5a22c 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -422,6 +422,11 @@ export async function runMedicationLowStockTick( rollingIntervalDays: true, // v1.37.19 — per-slot units feed the slot-aware burn rate. unitsPerDose: true, + // A PRN schedule consumes nothing on a cadence, a CYCLIC one + // only in its "on" weeks. + scheduleType: true, + cyclicOnWeeks: true, + cyclicOffWeeks: true, }, }, }, diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts index ae5b930ce..9a42d76d6 100644 --- a/src/lib/medications/inventory/__tests__/summary.test.ts +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -195,3 +195,14 @@ describe("supply surfaces — server computes, the detail-page clients render th expect(src).toContain("medications.detail.bestand.expiredSuffix"); }); }); + +describe("summariseSupply — whole doses survive binary floating point", () => { + it("2.4 mL at 0.8 mL per dose is three doses, not two", () => { + const summary = summariseSupply( + [{ state: "ACTIVE", unitsRemaining: 2.4, unitsTotal: 2.4 }] as never, + 0.8, + ); + expect(summary.dosesRemaining).toBe(3); + expect(summary.dosesTotal).toBe(3); + }); +}); diff --git a/src/lib/medications/inventory/summary.ts b/src/lib/medications/inventory/summary.ts index d1d5c6f21..ed764695a 100644 --- a/src/lib/medications/inventory/summary.ts +++ b/src/lib/medications/inventory/summary.ts @@ -30,6 +30,8 @@ * the client detail surfaces that also render from it. */ +import { floorWhole } from "@/lib/medications/units-per-dose"; + export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; export interface SupplyItem { @@ -102,8 +104,8 @@ export function summariseSupply( return { unitsRemaining, unitsTotal, - dosesRemaining: Math.floor(unitsRemaining / perDose), - dosesTotal: Math.floor(unitsTotal / perDose), + dosesRemaining: floorWhole(unitsRemaining / perDose), + dosesTotal: floorWhole(unitsTotal / perDose), expiredUnits, }; } diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 0556502e1..b9a5ca0a3 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -34,6 +34,7 @@ import { scheduleWireFields, } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; +import { floorWhole } from "@/lib/medications/units-per-dose"; export type MedicationsListResult = Array>; @@ -272,7 +273,7 @@ export async function buildMedicationsList( const stockDosesRemaining = stockUnitsRemaining === null ? null - : Math.floor( + : floorWhole( stockUnitsRemaining / effectiveUnitsPerDose(schedulesDto, Number(m.unitsPerDose)), ); diff --git a/src/lib/medications/units-per-dose.ts b/src/lib/medications/units-per-dose.ts index ba7d012f4..1c26a5af1 100644 --- a/src/lib/medications/units-per-dose.ts +++ b/src/lib/medications/units-per-dose.ts @@ -136,3 +136,16 @@ export function formatUnitsPerDose(value: number, locale?: string): string { useGrouping: false, }).format(rounded); } + +/** + * `Math.floor` for the supply arithmetic, tolerant of binary floating point. + * 2.4 / 0.8 is 2.9999999999999996 in IEEE 754, so a plain floor reports two + * doses left where there are three. Units and units per dose carry at most + * four decimals (per dose at most 100), so a quotient that is not a whole + * number sits at least 1e-6 below the next one; a rounding error sits + * orders of magnitude closer. Anything within 1e-9 of the next whole + * number is that number. + */ +export function floorWhole(value: number): number { + return Math.floor(value + 1e-9); +} From 62cef974bff23293ed55aef2459ff83330cafff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 15:38:20 +0200 Subject: [PATCH 4/4] fix(medications): scope a snooze to its slot and key report adherence by id A snooze lives on the medication row, and the reminder tick applied it to every slot of that medication. A Telegram "skip" of the morning dose snoozes to the end of the day, so the evening dose of the same medication was neither reminded nor marked missed by the reminder path. A snooze now holds only slots whose reminder window had opened when it was set, dated by the row's updatedAt that the snooze write stamps (the same reading the skip and pin suppression already uses). A later edit to the row can only widen the hold back to the old behaviour. The doctor report kept adherence in a map keyed by medication name, so two medications with the same name (an ended course and a new one, two strengths) collapsed into one row and the second overwrote the first. The map is keyed by medication id now and carries the name as the label; the report's medication list carries the id. The PDF, the FHIR adherence observations, the MCP visit summary, the clinician view and the GLP-1 section read it that way. --- .../share-view-leaf-render-guard.test.ts | 12 ++++- src/components/clinician/report-sections.tsx | 12 ++--- src/lib/__tests__/doctor-report-data.test.ts | Bin 17515 -> 18677 bytes .../__tests__/doctor-report-pdf-core.test.ts | 15 +++++-- src/lib/doctor-report-helpers.ts | 6 ++- .../medication-mood-wellness-section.ts | 4 +- src/lib/doctor-report-types.ts | 10 +++++ src/lib/doctor-report/collect.ts | 1 + src/lib/doctor-report/medications.ts | 2 +- src/lib/fhir/__tests__/build-bundle.test.ts | 29 +++++++++++-- src/lib/fhir/__tests__/conformance.test.ts | 12 ++++- src/lib/fhir/resources/observation.ts | 4 +- .../medication-reminder-check.test.ts | 41 ++++++++++++++++++ .../reminder/medication-reminder-check.ts | 39 +++++++++++++++-- src/lib/mcp/doctor-visit-summary.ts | 4 +- 15 files changed, 162 insertions(+), 29 deletions(-) diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index 5f073d4f5..50b591b21 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -225,7 +225,9 @@ const DISPOSITIONS: Record = { MEDICATION_LIST: { kind: "rendered", data: { - medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + medications: [ + { id: "med-ramipril", name: "Ramipril", dose: "5 mg", schedules: [] }, + ], }, marker: "Ramipril", }, @@ -252,7 +254,13 @@ const DISPOSITIONS: Record = { kind: "rendered", data: { compliance: { - Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 }, + "med-metformin": { + name: "Metformin", + total: 60, + taken: 57, + skipped: 1, + missed: 2, + }, }, }, marker: "Metformin", diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index 10efc951c..bf41f8535 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -309,14 +309,14 @@ export function MedicationsSection({ empty={medications.length === 0 && complianceEntries.length === 0} > {medications.map((med) => { - const comp = report.compliance[med.name]; + const comp = report.compliance[med.id]; const rate = complianceOn && comp && comp.total > 0 ? `${Math.round((comp.taken / comp.total) * 100)}%` : null; return ( !medications.some((m) => m.name === name)) - .map(([name, c]) => ( + .filter(([id]) => !medications.some((m) => m.id === id)) + .map(([id, c]) => ( 1gh5SEdMAQXruAS9yErMZLi+F-?zOaclLqCgf8fGpU%8{@*JnT{u*;z6kCncd^C8w#%2(>L?|%*@aCjbG=DFW-{0G2+w$%{5H0D04}r zR*XlIz>cIrkf7v*e^Y5FN+C|*-~pU$0)MR*94bInU;(+F8OtWuD@;L~U~S2u zdPO74l_8;mTpF|B6np`e-D@|WB;YA7*f3j!;MB9gVy@X)zduy9Xy?`>3>?mWGDs}A>PBRNKONImcd zzTsXrx7}8^fA%bUjX8-iE^Mr4lnBc^#YrMaKR%wxqxg9kgnjFY21zv$zMZnti)zY1 z_-lIUBaG{9OBMY?kVOO0{V#=NMOekj&8?lk$=)cG|3tS#;3>b$ugQ;#m#g{iMg#Yu f^>Gb#+MP9|+szePer}r!E9juJzd}pwY+U;d^Kan@ delta 100 zcmex5k@0m0): DoctorReportData { glucoseUnit: "mg/dL", bmi: 24.1, compliance: { - Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 }, + "med-ramipril": { + name: "Ramipril", + total: 90, + taken: 85, + skipped: 3, + missed: 2, + }, }, medications: [ { + id: "med-ramipril", name: "Ramipril", dose: "5mg", schedules: [ @@ -324,7 +331,8 @@ describe("doctor-report pagination", () => { it("paginates a long compliance table across pages without throwing", () => { const compliance: DoctorReportData["compliance"] = {}; for (let i = 0; i < 60; i++) { - compliance[`Medication ${i}`] = { + compliance[`med-${i}`] = { + name: `Medication ${i}`, total: 90, taken: 80, skipped: 5, @@ -899,7 +907,8 @@ describe("extracted doctor-report section boundaries", () => { it("footer is drawn once on every dynamically added page", async () => { const compliance: DoctorReportData["compliance"] = {}; for (let i = 0; i < 60; i += 1) { - compliance[`Medication ${i}`] = { + compliance[`med-${i}`] = { + name: `Medication ${i}`, total: 90, taken: 80, skipped: 5, diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index 3c5e2c93d..305fe5e70 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -344,7 +344,8 @@ export function normaliseDateRange( * schedule, no expected dose — a fabricated 100 % on a clinical report). * * Pure / synchronous — bands come from pre-fetched schedules + intake - * instants. Keyed by medication name to match the renderer contract. + * instants. Keyed by medication id: two medications can share a name, and + * a name key let the second overwrite the first. The name rides in the value. */ export function buildLedgerCompliance( medications: DoctorReportComplianceMedication[], @@ -385,7 +386,8 @@ export function buildLedgerCompliance( // `total = taken + missed` (the ledger denominator) so the renderer's // `taken / total` equals `tally.rate` — the exact number the detail page // shows. Deliberate skips are reported separately but stay out of `total`. - compliance[med.name] = { + compliance[med.id] = { + name: med.name, total: tally.denominator, taken: tally.taken, skipped: tally.skipped, diff --git a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts index ce7929457..61b436cff 100644 --- a/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts +++ b/src/lib/doctor-report-pdf/medication-mood-wellness-section.ts @@ -67,7 +67,7 @@ export function buildMedicationMoodWellnessSection( doc.text(t("doctorReport.complianceTitle"), margin, y); y += 6; - const compRows = complianceEntries.map(([name, c]) => { + const compRows = complianceEntries.map(([, c]) => { // v1.17 W1a — `total` is the ledger rate denominator (taken + missed, // deliberate skips excluded), so `taken / total` matches the app's // detail-page adherence %. The column is labelled "Expected" rather @@ -77,7 +77,7 @@ export function buildMedicationMoodWellnessSection( const ratePct = adherenceRatePercent(c.taken, c.total); const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—"; return [ - name, + c.name, String(c.taken), String(c.skipped), String(c.missed), diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 708b4de54..c37bab2c4 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -40,7 +40,14 @@ export interface DoctorReportStats { latest: number; } +/** + * One medication's adherence over the report window. The report's + * `compliance` map is keyed by medication id, because two medications may + * share a name (an ended course and a new one, two strengths); `name` is the + * label every renderer prints. + */ export interface DoctorReportCompliance { + name: string; total: number; taken: number; skipped: number; @@ -102,8 +109,11 @@ export interface DoctorReportData { */ glucoseClinical: GlucoseClinicalMetrics; bmi: number | null; + /** Keyed by medication id; see `DoctorReportCompliance`. */ compliance: Record; medications: Array<{ + /** The medication id, the key into `compliance`. */ + id: string; name: string; dose: string; // v1.9.0 — optional user/clinician-asserted drug-classification diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 502480dba..6dc45b463 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -583,6 +583,7 @@ export async function collectDoctorReportData( compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {}, medications: gate.admits("MEDICATION_LIST") ? medications.map((m) => ({ + id: m.id, name: m.name, dose: m.dose, atcCode: m.atcCode, diff --git a/src/lib/doctor-report/medications.ts b/src/lib/doctor-report/medications.ts index fa1def120..d6e420291 100644 --- a/src/lib/doctor-report/medications.ts +++ b/src/lib/doctor-report/medications.ts @@ -186,7 +186,7 @@ export function buildGlp1Block(params: { medications: glp1Meds.map((m) => { const latest = m.doseChanges[m.doseChanges.length - 1] ?? null; const lastIntake = m.intakeEvents[0] ?? null; - const comp = params.compliance[m.name] ?? { + const comp = params.compliance[m.id] ?? { taken: 0, total: 0, skipped: 0, diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index a0c2f5f07..c119dc128 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -111,10 +111,17 @@ function makeData(overrides?: Partial): DoctorReportData { glucoseUnit: "mg/dL", bmi: 24.1, compliance: { - "Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 }, + "med-example": { + name: "Example Drug", + total: 90, + taken: 85, + skipped: 3, + missed: 2, + }, }, medications: [ { + id: "med-example-drug", name: "Example Drug", dose: "5mg", schedules: [ @@ -472,6 +479,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-mounjaro", name: "Mounjaro", dose: "10mg", atcCode: "A10BX10", @@ -509,7 +517,13 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ medications: [ - { name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] }, + { + id: "med-ramipril", + name: "Ramipril", + dose: "5mg", + atcCode: "C09AA05", + schedules: [], + }, ], }), { insuranceNumber: null }, @@ -781,6 +795,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-empagliflozin", name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", @@ -810,6 +825,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-empagliflozin", name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", @@ -867,7 +883,14 @@ describe("buildFhirDocumentBundle", () => { it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => { const bundle = buildFhirDocumentBundle( makeData({ - medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }], + medications: [ + { + id: "med-herbal", + name: "Herbal mix", + dose: "1 tab", + schedules: [], + }, + ], }), { insuranceNumber: null }, FIXED_NOW, diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 8877f7dad..2cdc9cd53 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -65,9 +65,17 @@ function makeData(overrides?: Partial): DoctorReportData { glucoseUnit: "mg/dL", bmi: 24.1, compliance: { - "Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 }, + "med-example": { + name: "Example Drug", + total: 90, + taken: 85, + skipped: 3, + missed: 2, + }, }, - medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }], + medications: [ + { id: "med-example", name: "Example Drug", dose: "5mg", schedules: [] }, + ], medicationAdministrations: [ { medicationName: "Example Drug", diff --git a/src/lib/fhir/resources/observation.ts b/src/lib/fhir/resources/observation.ts index 45a8bc4a8..fc4c7a5be 100644 --- a/src/lib/fhir/resources/observation.ts +++ b/src/lib/fhir/resources/observation.ts @@ -174,7 +174,7 @@ function adherenceObservations( ): FhirObservation[] { const observations: FhirObservation[] = []; const period = reportingPeriod(data); - for (const [name, comp] of Object.entries(data.compliance)) { + for (const comp of Object.values(data.compliance)) { if (comp.total <= 0) continue; // Integer percent — the one canonical rounding the app card + PDF use, so // a clinician sees the same adherence figure on every surface. The helper @@ -197,7 +197,7 @@ function adherenceObservations( display: "Medication adherence", }, ], - text: `Medication adherence — ${name}`, + text: `Medication adherence — ${comp.name}`, }, subject: patientRef, effectivePeriod: period, diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index a6b259481..7bc66a553 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -851,3 +851,44 @@ describe("issue #664 — exact occurrence identity survives local midnight", () expect(dispatchNotification).not.toHaveBeenCalled(); }); }); + +describe("handleReminderCheck — a snooze holds only the slot it answered", () => { + function snoozed(at: string): Record { + return { + ...medicationWithSchedule({ timesOfDay: ["08:00", "18:00"] }), + // A Telegram "skip" of the morning dose snoozes to the end of the day; + // the snooze write stamps the row's updatedAt. + snoozedUntil: new Date("2026-07-28T23:59:59.000Z"), + updatedAt: new Date(at), + }; + } + + it("still reminds the evening dose after the morning one was snoozed", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T18:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + snoozed("2026-07-28T08:30:00.000Z"), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ timeOfDay: "18:00" }), + }), + ); + }); + + it("holds the slot whose reminder was open when the snooze was set", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T08:40:00.000Z")); + prismaMock.medication.findMany.mockResolvedValue([ + snoozed("2026-07-28T08:30:00.000Z"), + ] as never); + + await handleReminderCheck([]); + + expect(dispatchNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index fd46f6a15..c913a3af4 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -141,6 +141,24 @@ export async function cleanupScheduledTelegramDeletions(): Promise { } } +/** + * Whether a medication-level snooze holds the reminder of one slot. It + * holds only while it runs (`now < snoozedUntil`) and only for a slot whose + * reminder window had opened when the snooze was set (`snoozedAt`, the + * medication row's @updatedAt of the snooze write). A later edit to the + * row moves `snoozedAt` forward, which can only widen the hold back to the + * old medication-wide behaviour, never lose a snooze. + */ +export function snoozeHoldsSlot( + snoozedUntil: Date | null, + snoozedAt: Date, + slotWindowStart: Date, + now: Date, +): boolean { + if (!snoozedUntil || now.getTime() >= snoozedUntil.getTime()) return false; + return slotWindowStart.getTime() <= snoozedAt.getTime(); +} + /** * Check all active medications for each user and determine reminder phases. * Uses phase-based logic (GREEN/YELLOW/ORANGE/RED) to send one notification @@ -477,6 +495,7 @@ export async function handleReminderCheck( return currentPhase ? { slotScheduledFor: occurrence.at, + windowStart: phaseWindow.start, minutesToEnd, currentPhase, } @@ -489,8 +508,12 @@ export async function handleReminderCheck( )[0]; if (!phaseCandidate) continue; - const { slotScheduledFor, minutesToEnd, currentPhase } = - phaseCandidate; + const { + slotScheduledFor, + windowStart, + minutesToEnd, + currentPhase, + } = phaseCandidate; const slotInstant = slotScheduledFor.getTime(); if (liveEraStart !== null && slotInstant < liveEraStart) { continue; @@ -544,8 +567,16 @@ export async function handleReminderCheck( continue; } - // Skip if medication is snoozed - if (med.snoozedUntil && now < med.snoozedUntil) { + // A snooze answers the reminder of the slot that was open when it + // was set, so it holds only slots whose window had opened by then. + // `snoozedUntil` lives on the medication row and the snooze write + // stamps the row's @updatedAt, which dates the decision (the same + // reading the skip / pin suppression above uses). A later slot of + // the same medication, such as the evening dose after a morning + // "skip" that snoozes to the end of the day, is still reminded. + if ( + snoozeHoldsSlot(med.snoozedUntil, med.updatedAt, windowStart, now) + ) { continue; } diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts index f647cc071..6b00444d6 100644 --- a/src/lib/mcp/doctor-visit-summary.ts +++ b/src/lib/mcp/doctor-visit-summary.ts @@ -73,8 +73,8 @@ export function summariseForVisit( }; }); - const compliance = Object.entries(data.compliance).map(([name, c]) => ({ - medication: name, + const compliance = Object.values(data.compliance).map((c) => ({ + medication: c.name, takenDoses: c.taken, expectedDoses: c.total, missedDoses: c.missed,