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