mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Publish the resolved per-slot dose units and a slot-aware runway on the medication wire
A medication whose slots carry different doses (a whole tablet in the morning, a half at noon — #219) was consumed correctly but reported wrongly: the wire shipped only the raw nullable per-slot column (clients had to derive the inheritance), stockDosesRemaining divided by the medication-level units alone, and no runway figure existed outside the low-stock push. - Every schedule now carries resolvedUnitsPerDose, resolved server-side exactly like the intake consumption resolver. - stockDosesRemaining divides the units pool by the schedule-weighted average units per dose. - runwayDays (whole days of supply under the slot-aware burn rate) rides the list AND detail payloads; the detail GET also gains the stock aggregates the list already had. - The low-stock engine runs the same slot-aware math, so the push and the wire cannot disagree; the web cards, table, and detail Übersicht prefer the server figure over their client estimate. - OpenAPI regenerated with the new fields.
This commit is contained in:
1 parent
542ca91be5
commit
4b009c3a45
15 files changed
+510
-85
No files matched your search
@@ -8,8 +8,11 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
cadenceIntervalDays,
|
||||
classifyLowStockState,
|
||||
effectiveUnitsPerDose,
|
||||
estimateDailyDoseCount,
|
||||
estimateDailyUnitsCount,
|
||||
estimateRunwayDays,
|
||||
estimateUnitsRunwayDays,
|
||||
lowStockTriggerDays,
|
||||
supplyRunwayDates,
|
||||
type RunwaySchedule,
|
||||
@@ -290,3 +293,55 @@ describe("supplyRunwayDates — v1.17.0", () => {
|
||||
expect(reorderBy.toISOString().slice(0, 10)).toBe("2026-06-01");
|
||||
});
|
||||
});
|
||||
|
||||
// v1.37.19 (iOS #25 parity) — slot-aware burn rate.
|
||||
//
|
||||
// Watched red: with `estimateDailyUnitsCount` ignoring per-slot
|
||||
// `unitsPerDose` (the pre-fix behaviour — dose rate × medication-level
|
||||
// units only), the mixed-dose case below computes 2 units/day instead of
|
||||
// 1.5 and both the effective-units and the runway assertions fail.
|
||||
describe("slot-aware units math (#219 parity)", () => {
|
||||
const wholeMorning = schedule({ timesOfDay: ["08:00"], unitsPerDose: 1 });
|
||||
const halfNoon = schedule({ timesOfDay: ["12:00"], unitsPerDose: 0.5 });
|
||||
|
||||
it("weights each slot's own unitsPerDose by its cadence share", () => {
|
||||
// 1 unit + 0.5 unit per day = 1.5 units/day.
|
||||
expect(estimateDailyUnitsCount([wholeMorning, halfNoon], 1)).toBeCloseTo(
|
||||
1.5,
|
||||
);
|
||||
});
|
||||
|
||||
it("inherits the medication level for a NULL per-slot value", () => {
|
||||
expect(
|
||||
estimateDailyUnitsCount(
|
||||
[schedule({ timesOfDay: ["08:00"], unitsPerDose: null }), halfNoon],
|
||||
2,
|
||||
),
|
||||
).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it("derives the schedule-weighted average units per dose", () => {
|
||||
// 1.5 units over 2 doses per day → 0.75 units per dose.
|
||||
expect(effectiveUnitsPerDose([wholeMorning, halfNoon], 1)).toBeCloseTo(
|
||||
0.75,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the medication level with no consuming schedule", () => {
|
||||
expect(effectiveUnitsPerDose([], 2)).toBe(2);
|
||||
expect(effectiveUnitsPerDose([], 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("projects the units runway under the slot-aware rate", () => {
|
||||
// 15 units at 1.5 units/day → 10 days.
|
||||
expect(estimateUnitsRunwayDays(15, [wholeMorning, halfNoon], 1)).toBe(10);
|
||||
});
|
||||
|
||||
it("is honest about an exhausted supply (0 days, not null)", () => {
|
||||
expect(estimateUnitsRunwayDays(0, [wholeMorning], 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null with no consuming cadence", () => {
|
||||
expect(estimateUnitsRunwayDays(10, [], 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,12 @@ export interface MedicationDetailSnapshot {
|
||||
* directly; no client-side recurrence walking.
|
||||
*/
|
||||
nextDueAt?: string | null;
|
||||
/**
|
||||
* v1.37.19 — server-resolved slot-aware runway (days); null = inventory
|
||||
* tracking off or no consuming cadence. Preferred over the client
|
||||
* estimate; the estimate remains only as the stale-payload fallback.
|
||||
*/
|
||||
runwayDays?: number | null;
|
||||
schedules: ScheduleSnapshot[];
|
||||
}
|
||||
|
||||
@@ -424,7 +430,11 @@ export function MedicationDetailTabs({
|
||||
medication.unitsPerDose && medication.unitsPerDose > 0
|
||||
? medication.unitsPerDose
|
||||
: 1;
|
||||
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
|
||||
// v1.37.19 — prefer the server's slot-aware runway from the detail GET.
|
||||
const runwayDays =
|
||||
medication.runwayDays !== undefined
|
||||
? medication.runwayDays
|
||||
: estimateRunwayDays(dosesRemaining, medication.schedules);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
|
||||
@@ -18,61 +18,128 @@ export interface RunwaySchedule {
|
||||
timesOfDay?: string[];
|
||||
rrule?: string | null;
|
||||
rollingIntervalDays?: number | null;
|
||||
/**
|
||||
* v1.37.19 (#219 parity) — per-slot units override as a plain number
|
||||
* (the wire shape). `null`/`undefined` inherits the medication-level
|
||||
* `unitsPerDose`, exactly like the consumption path's resolver.
|
||||
*/
|
||||
unitsPerDose?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate doses per day across every schedule: times-of-day count,
|
||||
* scaled down by the cadence (rolling interval, weekly day picks +
|
||||
* interval weeks, monthly/yearly RRULEs).
|
||||
* Approximate doses per day for ONE schedule: times-of-day count, scaled
|
||||
* down by the cadence (rolling interval, weekly day picks + interval
|
||||
* weeks, monthly/yearly RRULEs).
|
||||
*/
|
||||
function scheduleDailyDoseCount(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;
|
||||
}
|
||||
if (/FREQ=YEARLY/.test(rrule)) {
|
||||
return times / 365;
|
||||
}
|
||||
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the
|
||||
// create path stores the cadence on the rrule and leaves daysOfWeek
|
||||
// empty). Honour the BYDAY pick count and the week INTERVAL: a
|
||||
// once-weekly injection is one dose per 7 days, a bi-weekly one per
|
||||
// 14. Without this branch a weekly rrule fell through to the legacy
|
||||
// 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;
|
||||
return (times * bydayCount) / (7 * interval);
|
||||
}
|
||||
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
|
||||
const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7;
|
||||
const weeks = intervalWeeks >= 1 ? intervalWeeks : 1;
|
||||
return (times * daysPerWeek) / (7 * weeks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate doses per day across every schedule.
|
||||
*/
|
||||
export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number {
|
||||
let perDay = 0;
|
||||
for (const s of schedules) {
|
||||
const times =
|
||||
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
|
||||
if (
|
||||
typeof s.rollingIntervalDays === "number" &&
|
||||
s.rollingIntervalDays >= 1
|
||||
) {
|
||||
perDay += times / s.rollingIntervalDays;
|
||||
continue;
|
||||
}
|
||||
const rrule = s.rrule ?? "";
|
||||
if (/FREQ=MONTHLY/.test(rrule)) {
|
||||
perDay += times / 30;
|
||||
continue;
|
||||
}
|
||||
if (/FREQ=YEARLY/.test(rrule)) {
|
||||
perDay += times / 365;
|
||||
continue;
|
||||
}
|
||||
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the
|
||||
// create path stores the cadence on the rrule and leaves daysOfWeek
|
||||
// empty). Honour the BYDAY pick count and the week INTERVAL: a
|
||||
// once-weekly injection is one dose per 7 days, a bi-weekly one per
|
||||
// 14. Without this branch a weekly rrule fell through to the legacy
|
||||
// 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;
|
||||
perDay += (times * bydayCount) / (7 * interval);
|
||||
continue;
|
||||
}
|
||||
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
|
||||
const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7;
|
||||
const weeks = intervalWeeks >= 1 ? intervalWeeks : 1;
|
||||
perDay += (times * daysPerWeek) / (7 * weeks);
|
||||
perDay += scheduleDailyDoseCount(s);
|
||||
}
|
||||
return perDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.19 (#219 parity) — approximate inventory UNITS consumed per day,
|
||||
* honouring each slot's own `unitsPerDose` where set. The pre-fix figure
|
||||
* multiplied the dose rate by the medication-level units alone, which
|
||||
* over- or under-stated the burn rate for any medication whose slots
|
||||
* carry different doses (a whole tablet in the morning, a half at noon).
|
||||
*/
|
||||
export function estimateDailyUnitsCount(
|
||||
schedules: RunwaySchedule[],
|
||||
medicationUnitsPerDose: number,
|
||||
): number {
|
||||
const fallback = medicationUnitsPerDose > 0 ? medicationUnitsPerDose : 1;
|
||||
let perDayUnits = 0;
|
||||
for (const s of schedules) {
|
||||
const perSlot =
|
||||
typeof s.unitsPerDose === "number" && s.unitsPerDose > 0
|
||||
? s.unitsPerDose
|
||||
: fallback;
|
||||
perDayUnits += scheduleDailyDoseCount(s) * perSlot;
|
||||
}
|
||||
return perDayUnits;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule-weighted average units one dose consumes — the divisor that
|
||||
* turns a units pool into an honest doses-remaining figure for a
|
||||
* medication with per-slot doses. Falls back to the medication level when
|
||||
* no schedule derives a consumption rate.
|
||||
*/
|
||||
export function effectiveUnitsPerDose(
|
||||
schedules: RunwaySchedule[],
|
||||
medicationUnitsPerDose: number,
|
||||
): number {
|
||||
const fallback = medicationUnitsPerDose > 0 ? medicationUnitsPerDose : 1;
|
||||
const perDay = estimateDailyDoseCount(schedules);
|
||||
if (perDay <= 0) return fallback;
|
||||
const perDayUnits = estimateDailyUnitsCount(
|
||||
schedules,
|
||||
medicationUnitsPerDose,
|
||||
);
|
||||
return perDayUnits > 0 ? perDayUnits / perDay : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.19 (#219 parity) — whole days a UNITS pool covers under the
|
||||
* slot-aware burn rate, or `null` when no consuming schedule exists.
|
||||
* Zero units with a consuming schedule is honestly 0 days.
|
||||
*/
|
||||
export function estimateUnitsRunwayDays(
|
||||
unitsRemaining: number,
|
||||
schedules: RunwaySchedule[],
|
||||
medicationUnitsPerDose: number,
|
||||
): number | null {
|
||||
const perDayUnits = estimateDailyUnitsCount(
|
||||
schedules,
|
||||
medicationUnitsPerDose,
|
||||
);
|
||||
if (perDayUnits <= 0) return null;
|
||||
return Math.floor(Math.max(0, unitsRemaining) / perDayUnits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole days the remaining supply covers, or `null` when no estimate is
|
||||
* possible (no supply, no consuming schedule).
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface Glp1Medication {
|
||||
nextDueOverdue?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
|
||||
runwayDays?: number | null;
|
||||
/** v1.17.0 — per-medication reorder lead override (days); null = inherit the user default. */
|
||||
reorderLeadDays?: number | null;
|
||||
schedules: ScheduleLite[];
|
||||
@@ -370,17 +372,21 @@ export function Glp1MedicationCard({
|
||||
medication.reorderLeadDays != null
|
||||
? medication.reorderLeadDays
|
||||
: (thresholds?.reorderLeadDays ?? 10);
|
||||
// v1.37.19 — prefer the server's slot-aware `runwayDays`; client
|
||||
// estimate only as the stale-payload fallback.
|
||||
const stockRunwayDays =
|
||||
medication.stockDosesRemaining == null
|
||||
? null
|
||||
: medication.stockDosesRemaining > 0
|
||||
? estimateRunwayDays(
|
||||
medication.stockDosesRemaining,
|
||||
medication.schedules,
|
||||
)
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
: medication.runwayDays !== undefined
|
||||
? medication.runwayDays
|
||||
: medication.stockDosesRemaining > 0
|
||||
? estimateRunwayDays(
|
||||
medication.stockDosesRemaining,
|
||||
medication.schedules,
|
||||
)
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockTrigger =
|
||||
lowStockFloor === null
|
||||
? null
|
||||
|
||||
@@ -105,6 +105,8 @@ interface Medication {
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
|
||||
runwayDays?: number | null;
|
||||
/**
|
||||
* v1.17.0 — optional per-medication reorder lead override (days); null /
|
||||
* absent = inherit the user-level reorderLeadDays default. Widens the
|
||||
@@ -313,17 +315,22 @@ export function MedicationCard({
|
||||
medication.reorderLeadDays != null
|
||||
? medication.reorderLeadDays
|
||||
: (thresholds?.reorderLeadDays ?? 10);
|
||||
// v1.37.19 — prefer the server's slot-aware `runwayDays` (published on
|
||||
// the list wire); the client estimate stays only as the stale-payload
|
||||
// fallback.
|
||||
const stockRunwayDays =
|
||||
medication.asNeeded || medication.stockDosesRemaining == null
|
||||
? null
|
||||
: medication.stockDosesRemaining > 0
|
||||
? estimateRunwayDays(
|
||||
medication.stockDosesRemaining,
|
||||
medication.schedules,
|
||||
)
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
: medication.runwayDays !== undefined
|
||||
? medication.runwayDays
|
||||
: medication.stockDosesRemaining > 0
|
||||
? estimateRunwayDays(
|
||||
medication.stockDosesRemaining,
|
||||
medication.schedules,
|
||||
)
|
||||
: estimateDailyDoseCount(medication.schedules) > 0
|
||||
? 0
|
||||
: null;
|
||||
const lowStockTrigger =
|
||||
lowStockFloor === null
|
||||
? null
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface TableMedication {
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
|
||||
runwayDays?: number | null;
|
||||
schedules: TableSchedule[];
|
||||
}
|
||||
|
||||
@@ -599,10 +601,14 @@ function MedicationTableRowItem({
|
||||
// Projected days the stock covers — the same coarse estimate the
|
||||
// detail Übersicht renders, so the column answers "do I need a
|
||||
// refill" without opening the medication.
|
||||
// v1.37.19 — prefer the server's slot-aware `runwayDays`; client
|
||||
// estimate only as the stale-payload fallback.
|
||||
const runwayDays =
|
||||
stock === null || stock === undefined
|
||||
? null
|
||||
: estimateRunwayDays(stock, medication.schedules);
|
||||
medication.runwayDays !== undefined
|
||||
? medication.runwayDays
|
||||
: stock === null || stock === undefined
|
||||
? null
|
||||
: estimateRunwayDays(stock, medication.schedules);
|
||||
// Warn tier (amber) on the SAME predicate as the low-stock
|
||||
// notification: projected runway days strictly below the user's
|
||||
// threshold. Threshold OFF (null) → no amber tier; an exhausted stock
|
||||
|
||||
Reference in new issue
Block a user