From 91571d4674e2f9d8499fe3dafdd7f7c7e68cf6f3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 26 Sep 2026 15:32:29 +0200
Subject: [PATCH] fix(medications): count the whole cadence in the supply
runway
The runway estimate read FREQ=MONTHLY as one dose every 30 days whatever
its INTERVAL, so a quarterly injection with two doses left showed about 60
days of supply instead of 180. FREQ=DAILY;INTERVAL=2 counted as daily, a
yearly rule ignored INTERVAL and BYMONTH, and a monthly rule ignored extra
BYMONTHDAY values. A CYCLIC schedule was counted as if it never paused,
and a PRN schedule as if it were due every day. The rate now honours
INTERVAL for every frequency, counts the listed days and months, scales a
cyclic schedule by on / (on + off) and leaves PRN out. The low-stock job
selects the schedule type and cycle lengths so it reads the same rate.
Whole-dose counts no longer lose one to binary floating point: 2.4 mL at
0.8 mL per dose is 2.9999999999999996 in IEEE 754 and used to read as two
doses. The stock summary, both runway estimators, the list and detail
routes, the GLP-1 route, the Coach snapshot and the inventory readout now
floor through one helper that treats anything within 1e-9 of the next whole
number as that number.
---
src/app/api/medications/[id]/glp1/route.ts | 3 +-
src/app/api/medications/[id]/route.ts | 3 +-
.../__tests__/supply-runway.test.ts | 75 +++++++++++++++++++
.../medications/detail/supply-runway.ts | 69 +++++++++++++----
.../sections/inventory-dialogs.tsx | 5 +-
.../sections/inventory-section.tsx | 9 ++-
src/lib/ai/coach/glp1-snapshot.ts | 3 +-
src/lib/jobs/medication-low-stock.ts | 5 ++
.../inventory/__tests__/summary.test.ts | 11 +++
src/lib/medications/inventory/summary.ts | 6 +-
src/lib/medications/list-read.ts | 3 +-
src/lib/medications/units-per-dose.ts | 13 ++++
12 files changed, 180 insertions(+), 25 deletions(-)
diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts
index bb3f0c689..c8db311ee 100644
--- a/src/app/api/medications/[id]/glp1/route.ts
+++ b/src/app/api/medications/[id]/glp1/route.ts
@@ -47,6 +47,7 @@ import {
} from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
import { dueSchedules } from "@/lib/medications/intake-tracking";
+import { floorWhole } from "@/lib/medications/units-per-dose";
type RouteParams = { params: Promise<{ id: string }> };
@@ -175,7 +176,7 @@ export const GET = apiHandler(
0,
);
const pensRemaining = usable.length;
- const dosesRemaining = Math.floor(
+ const dosesRemaining = floorWhole(
unitsRemaining / (Number(medication.unitsPerDose) || 1),
);
const weeksOfSupply = dosesRemaining;
diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts
index 2d281fbff..ff3a6a91c 100644
--- a/src/app/api/medications/[id]/route.ts
+++ b/src/app/api/medications/[id]/route.ts
@@ -46,6 +46,7 @@ import {
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
+import { floorWhole } from "@/lib/medications/units-per-dose";
type RouteParams = { params: Promise<{ id: string }> };
@@ -216,7 +217,7 @@ export const GET = apiHandler(
const stockDosesRemaining =
stockUnitsRemaining === null
? null
- : Math.floor(
+ : floorWhole(
stockUnitsRemaining /
effectiveUnitsPerDose(
schedulesDto,
diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts
index dd16adf8a..90c861570 100644
--- a/src/components/medications/__tests__/supply-runway.test.ts
+++ b/src/components/medications/__tests__/supply-runway.test.ts
@@ -371,3 +371,78 @@ describe("units runway with a mixed units-per-dose value (#1034)", () => {
expect(estimateUnitsRunwayDays(10, [morning], 1.2)).toBe(8);
});
});
+
+describe("estimateDailyDoseCount — cadence parts the rate used to drop", () => {
+ it("stretches a monthly RRULE by its INTERVAL (every third month)", () => {
+ const perDay = estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }),
+ ]);
+ expect(perDay).toBeCloseTo(1 / 90, 10);
+ // Two doses left last about half a year, not two months.
+ expect(
+ estimateRunwayDays(2, [
+ schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }),
+ ]),
+ ).toBe(180);
+ });
+
+ it("counts each BYMONTHDAY of a monthly RRULE", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,15" }),
+ ]),
+ ).toBeCloseTo(2 / 30, 10);
+ });
+
+ it("stretches a daily RRULE by its INTERVAL (every other day)", () => {
+ expect(
+ estimateDailyDoseCount([schedule({ rrule: "FREQ=DAILY;INTERVAL=2" })]),
+ ).toBeCloseTo(1 / 2, 10);
+ });
+
+ it("stretches a yearly RRULE by its INTERVAL and counts BYMONTH", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYMONTHDAY=1" }),
+ ]),
+ ).toBeCloseTo(1 / 730, 10);
+ expect(
+ estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=YEARLY;BYMONTH=3,9;BYMONTHDAY=1" }),
+ ]),
+ ).toBeCloseTo(2 / 365, 10);
+ });
+
+ it("scales a CYCLIC schedule by its on share (3 weeks on, 1 off)", () => {
+ const cyclic = schedule({
+ rrule: "FREQ=DAILY",
+ scheduleType: "CYCLIC",
+ cyclicOnWeeks: 3,
+ cyclicOffWeeks: 1,
+ });
+ expect(estimateDailyDoseCount([cyclic])).toBeCloseTo(3 / 4, 10);
+ expect(estimateRunwayDays(21, [cyclic])).toBe(28);
+ });
+
+ it("counts nothing for an as-needed (PRN) schedule", () => {
+ expect(estimateDailyDoseCount([schedule({ scheduleType: "PRN" })])).toBe(0);
+ });
+});
+
+describe("whole counts survive binary floating point", () => {
+ it("2.4 units at 0.8 per dose is three doses", () => {
+ // 2.4 / 0.8 === 2.9999999999999996 in IEEE 754.
+ expect(effectiveUnitsPerDose([schedule({})], 0.8)).toBe(0.8);
+ expect(estimateUnitsRunwayDays(2.4, [schedule({})], 0.8)).toBe(3);
+ });
+
+ it("a dose-count runway that divides exactly is not a day short", () => {
+ // Three doses on Mon/Wed/Fri: 9/7 a day, and 9 / (9/7) is
+ // 6.999999999999999 in IEEE 754 — a week of supply, not six days.
+ const s = schedule({
+ daysOfWeek: "1,3,5",
+ timesOfDay: ["08:00", "12:00", "20:00"],
+ });
+ expect(estimateRunwayDays(9, [s])).toBe(7);
+ });
+});
diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts
index 77c58b4a5..1041acb0e 100644
--- a/src/components/medications/detail/supply-runway.ts
+++ b/src/components/medications/detail/supply-runway.ts
@@ -10,6 +10,7 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
+import { floorWhole } from "@/lib/medications/units-per-dose";
/** The schedule fields the runway estimate reads. */
export interface RunwaySchedule {
@@ -24,6 +25,30 @@ export interface RunwaySchedule {
* `unitsPerDose`, exactly like the consumption path's resolver.
*/
unitsPerDose?: number | null;
+ /**
+ * Schedule type. PRN consumes nothing on a cadence; CYCLIC consumes only
+ * in its "on" weeks, so its rate is scaled by on / (on + off).
+ */
+ scheduleType?: "SCHEDULED" | "PRN" | "CYCLIC" | string | null;
+ cyclicOnWeeks?: number | null;
+ cyclicOffWeeks?: number | null;
+}
+
+/** A positive integer RRULE part (`INTERVAL=3`), or `fallback`. */
+function rrulePositiveInt(
+ rrule: string,
+ key: string,
+ fallback: number,
+): number {
+ const m = new RegExp(`(?:^|;)${key}=(\\d+)(?:;|$)`, "i").exec(rrule);
+ const n = m ? Number(m[1]) : NaN;
+ return Number.isInteger(n) && n >= 1 ? n : fallback;
+}
+
+/** How many comma-separated values an RRULE list part carries, or 0. */
+function rruleListCount(rrule: string, key: string): number {
+ const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule);
+ return m ? m[1].split(",").filter((v) => v.trim() !== "").length : 0;
}
/**
@@ -32,17 +57,40 @@ export interface RunwaySchedule {
* weeks, monthly/yearly RRULEs).
*/
function scheduleDailyDoseCount(s: RunwaySchedule): number {
+ if (s.scheduleType === "PRN") return 0;
+ const rate = cadenceDailyDoseCount(s);
+ if (s.scheduleType === "CYCLIC") {
+ // Doses fall only in the "on" weeks of the cycle.
+ const on = s.cyclicOnWeeks ?? 0;
+ const off = s.cyclicOffWeeks ?? 0;
+ if (on > 0 && off > 0) return (rate * on) / (on + off);
+ }
+ return rate;
+}
+
+function cadenceDailyDoseCount(s: RunwaySchedule): number {
const times =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 1) {
return times / s.rollingIntervalDays;
}
const rrule = s.rrule ?? "";
- if (/FREQ=MONTHLY/.test(rrule)) {
- return times / 30;
+ // INTERVAL stretches every calendar frequency: FREQ=MONTHLY;INTERVAL=3 is
+ // one dose a quarter, not one a month.
+ const interval = rrulePositiveInt(rrule, "INTERVAL", 1);
+ if (/FREQ=DAILY/i.test(rrule)) {
+ return times / interval;
}
- if (/FREQ=YEARLY/.test(rrule)) {
- return times / 365;
+ if (/FREQ=MONTHLY/i.test(rrule)) {
+ const perMonth = Math.max(
+ 1,
+ rruleListCount(rrule, "BYMONTHDAY") || rruleListCount(rrule, "BYDAY"),
+ );
+ return (times * perMonth) / (30 * interval);
+ }
+ if (/FREQ=YEARLY/i.test(rrule)) {
+ const perYear = Math.max(1, rruleListCount(rrule, "BYMONTH"));
+ return (times * perYear) / (365 * interval);
}
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the
// create path stores the cadence on the rrule and leaves daysOfWeek
@@ -52,14 +100,7 @@ function scheduleDailyDoseCount(s: RunwaySchedule): number {
// daysOfWeek fallback below with daysPerWeek=7, over-estimating the
// rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early.
if (/FREQ=WEEKLY/.test(rrule)) {
- const byday = /BYDAY=([^;]+)/.exec(rrule);
- const bydayCount =
- byday && byday[1].length > 0 ? byday[1].split(",").length : 1;
- const intervalMatch = /INTERVAL=(\d+)/.exec(rrule);
- const interval =
- intervalMatch && Number(intervalMatch[1]) >= 1
- ? Number(intervalMatch[1])
- : 1;
+ const bydayCount = Math.max(1, rruleListCount(rrule, "BYDAY"));
return (times * bydayCount) / (7 * interval);
}
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
@@ -137,7 +178,7 @@ export function estimateUnitsRunwayDays(
medicationUnitsPerDose,
);
if (perDayUnits <= 0) return null;
- return Math.floor(Math.max(0, unitsRemaining) / perDayUnits);
+ return floorWhole(Math.max(0, unitsRemaining) / perDayUnits);
}
/**
@@ -151,7 +192,7 @@ export function estimateRunwayDays(
if (dosesRemaining <= 0) return null;
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) return null;
- return Math.floor(dosesRemaining / perDay);
+ return floorWhole(dosesRemaining / perDay);
}
/**
diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx
index c2f95cec8..73939f220 100644
--- a/src/components/medications/sections/inventory-dialogs.tsx
+++ b/src/components/medications/sections/inventory-dialogs.tsx
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
+ floorWhole,
formatUnitsPerDose,
parseUnitsPerDoseInput,
} from "@/lib/medications/units-per-dose";
@@ -267,10 +268,10 @@ export function AddInventoryDialog({
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
- Math.floor(parsed / unitsPerDose) === 0
+ floorWhole(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
- doses: Math.floor(parsed / unitsPerDose),
+ doses: floorWhole(parsed / unitsPerDose),
})}
)}
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index e6128df32..44750aa0d 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -60,7 +60,10 @@ import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/date-format";
import { queryKeys } from "@/lib/query-keys";
import { formatUnitCount } from "@/components/medications/units-per-dose";
-import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
+import {
+ floorWhole,
+ formatUnitsPerDose,
+} from "@/lib/medications/units-per-dose";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import {
@@ -382,8 +385,8 @@ export function InventorySection({
{item.unitsRemaining == null || item.unitsTotal == null
? t("medications.detail.bestand.unknown")
: t("medications.detail.bestand.doses", {
- remaining: Math.floor(item.unitsRemaining / perDose),
- total: Math.floor(item.unitsTotal / perDose),
+ remaining: floorWhole(item.unitsRemaining / perDose),
+ total: floorWhole(item.unitsTotal / perDose),
})}
{perDose !== 1 &&
item.unitsRemaining != null &&
diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts
index c452eaa8e..0a17ba5ca 100644
--- a/src/lib/ai/coach/glp1-snapshot.ts
+++ b/src/lib/ai/coach/glp1-snapshot.ts
@@ -24,6 +24,7 @@ import { readNote } from "@/lib/crypto/note-cipher";
import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match";
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
+import { floorWhole } from "@/lib/medications/units-per-dose";
/**
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
@@ -372,7 +373,7 @@ export async function buildGlp1SnapshotBlock(
0,
);
pensRemaining = usable.length;
- dosesRemaining = Math.floor(
+ dosesRemaining = floorWhole(
unitsRemaining / (Number(med.unitsPerDose) || 1),
);
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts
index 9990943fc..86df5a22c 100644
--- a/src/lib/jobs/medication-low-stock.ts
+++ b/src/lib/jobs/medication-low-stock.ts
@@ -422,6 +422,11 @@ export async function runMedicationLowStockTick(
rollingIntervalDays: true,
// v1.37.19 — per-slot units feed the slot-aware burn rate.
unitsPerDose: true,
+ // A PRN schedule consumes nothing on a cadence, a CYCLIC one
+ // only in its "on" weeks.
+ scheduleType: true,
+ cyclicOnWeeks: true,
+ cyclicOffWeeks: true,
},
},
},
diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts
index ae5b930ce..9a42d76d6 100644
--- a/src/lib/medications/inventory/__tests__/summary.test.ts
+++ b/src/lib/medications/inventory/__tests__/summary.test.ts
@@ -195,3 +195,14 @@ describe("supply surfaces — server computes, the detail-page clients render th
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
});
+
+describe("summariseSupply — whole doses survive binary floating point", () => {
+ it("2.4 mL at 0.8 mL per dose is three doses, not two", () => {
+ const summary = summariseSupply(
+ [{ state: "ACTIVE", unitsRemaining: 2.4, unitsTotal: 2.4 }] as never,
+ 0.8,
+ );
+ expect(summary.dosesRemaining).toBe(3);
+ expect(summary.dosesTotal).toBe(3);
+ });
+});
diff --git a/src/lib/medications/inventory/summary.ts b/src/lib/medications/inventory/summary.ts
index d1d5c6f21..ed764695a 100644
--- a/src/lib/medications/inventory/summary.ts
+++ b/src/lib/medications/inventory/summary.ts
@@ -30,6 +30,8 @@
* the client detail surfaces that also render from it.
*/
+import { floorWhole } from "@/lib/medications/units-per-dose";
+
export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
export interface SupplyItem {
@@ -102,8 +104,8 @@ export function summariseSupply(
return {
unitsRemaining,
unitsTotal,
- dosesRemaining: Math.floor(unitsRemaining / perDose),
- dosesTotal: Math.floor(unitsTotal / perDose),
+ dosesRemaining: floorWhole(unitsRemaining / perDose),
+ dosesTotal: floorWhole(unitsTotal / perDose),
expiredUnits,
};
}
diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts
index 0556502e1..b9a5ca0a3 100644
--- a/src/lib/medications/list-read.ts
+++ b/src/lib/medications/list-read.ts
@@ -34,6 +34,7 @@ import {
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
+import { floorWhole } from "@/lib/medications/units-per-dose";
export type MedicationsListResult = Array>;
@@ -272,7 +273,7 @@ export async function buildMedicationsList(
const stockDosesRemaining =
stockUnitsRemaining === null
? null
- : Math.floor(
+ : floorWhole(
stockUnitsRemaining /
effectiveUnitsPerDose(schedulesDto, Number(m.unitsPerDose)),
);
diff --git a/src/lib/medications/units-per-dose.ts b/src/lib/medications/units-per-dose.ts
index ba7d012f4..1c26a5af1 100644
--- a/src/lib/medications/units-per-dose.ts
+++ b/src/lib/medications/units-per-dose.ts
@@ -136,3 +136,16 @@ export function formatUnitsPerDose(value: number, locale?: string): string {
useGrouping: false,
}).format(rounded);
}
+
+/**
+ * `Math.floor` for the supply arithmetic, tolerant of binary floating point.
+ * 2.4 / 0.8 is 2.9999999999999996 in IEEE 754, so a plain floor reports two
+ * doses left where there are three. Units and units per dose carry at most
+ * four decimals (per dose at most 100), so a quotient that is not a whole
+ * number sits at least 1e-6 below the next one; a rounding error sits
+ * orders of magnitude closer. Anything within 1e-9 of the next whole
+ * number is that number.
+ */
+export function floorWhole(value: number): number {
+ return Math.floor(value + 1e-9);
+}