mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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