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/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/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]) => ( { 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/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 44c70a82a..174171177 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index a3295758f..b525b4857 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -71,10 +71,17 @@ function makeData(overrides?: Partial): 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/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/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 b454d2002..e8844d820 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -112,10 +112,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: [ @@ -473,6 +480,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-mounjaro", name: "Mounjaro", dose: "10mg", atcCode: "A10BX10", @@ -510,7 +518,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 }, @@ -782,6 +796,7 @@ describe("buildFhirDocumentBundle", () => { makeData({ medications: [ { + id: "med-empagliflozin", name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", @@ -811,6 +826,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 195e5f618..693da6fdf 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 72fc8d3af..1dd533cf8 100644 --- a/src/lib/fhir/resources/observation.ts +++ b/src/lib/fhir/resources/observation.ts @@ -177,7 +177,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 @@ -201,7 +201,7 @@ function adherenceObservations( "Adherence to prescribed medication instructions [Reported]", }, ], - text: `Medication adherence — ${name}`, + text: `Medication adherence — ${comp.name}`, }, subject: patientRef, effectivePeriod: period, 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/__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/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) { 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/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, 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/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/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"); 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); +}