mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
1 parent
888b738546
commit
91571d4674
12 files changed
+180
-25
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user