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:
Marc-André Bombeck committed 2026-08-14 05:41:15 +02:00
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
+15 -8
View File
@@ -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