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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:32:29 +02:00
1 parent 888b738546
commit 91571d4674
12 files changed
+180 -25

No files matched your search

+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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,
@@ -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);
});
});
@@ -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);
}
/**
@@ -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),
})}
</p>
)}
@@ -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 &&
+2 -1
View File
@@ -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);
+5
View File
@@ -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,
},
},
},
@@ -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);
});
});
+4 -2
View File
@@ -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,
};
}
+2 -1
View File
@@ -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<Record<string, unknown>>;
@@ -272,7 +273,7 @@ export async function buildMedicationsList(
const stockDosesRemaining =
stockUnitsRemaining === null
? null
: Math.floor(
: floorWhole(
stockUnitsRemaining /
effectiveUnitsPerDose(schedulesDto, Number(m.unitsPerDose)),
);
+13
View File
@@ -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);
}