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);
+}