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