diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 2491c59a9..cc399cdda 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -17853,6 +17853,18 @@ components:
- type: string
- type: "null"
description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.
+ unitsPerDose:
+ anyOf:
+ - type: number
+ - type: "null"
+ description: v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule
+ inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value
+ from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`.
+ resolvedUnitsPerDose:
+ type: number
+ description: v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose
+ ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never
+ re-derive the inheritance rule.
daysOfWeek:
anyOf:
- type: string
@@ -17917,6 +17929,8 @@ components:
- windowEnd
- label
- dose
+ - unitsPerDose
+ - resolvedUnitsPerDose
- daysOfWeek
- timesOfDay
- reminderGraceMinutes
@@ -23639,9 +23653,20 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
- description: "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a
- fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives
+ description: "v1.16.10 — dose-derived stock as a whole-dose count. v1.37.19 — slot-aware: the divisor is the
+ schedule-weighted average units per dose (each slot's `resolvedUnitsPerDose` weighted by its cadence share),
+ falling back to the medication-level `unitsPerDose` when no schedule derives a consumption rate.
+ `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). NULL when inventory tracking is off. Drives
the table view's Bestand column. Read-only — aggregated, not stored."
+ runwayDays:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the
+ low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking
+ off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored.
required:
- id
- name
@@ -23673,6 +23698,7 @@ components:
- todayEventCount
- stockUnitsRemaining
- stockDosesRemaining
+ - runwayDays
additionalProperties: false
description: List-row variant of the medication resource enriched with the joined `category`, the v1.32.25
`externalSource` provenance echo, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields
@@ -23841,6 +23867,27 @@ components:
$ref: "#/components/schemas/MedicationScheduleOutput"
category:
$ref: "#/components/schemas/MedicationCategory"
+ stockUnitsRemaining:
+ anyOf:
+ - type: number
+ - type: "null"
+ description: "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory
+ tracking off; 0 = tracking on, supply ran out."
+ stockDosesRemaining:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ description: v1.37.19 — slot-aware dose-derived stock, mirroring the list entry's field.
+ runwayDays:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ description: v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off
+ or no consuming cadence.
required:
- id
- name
@@ -23865,6 +23912,9 @@ components:
- updatedAt
- schedules
- category
+ - stockUnitsRemaining
+ - stockDosesRemaining
+ - runwayDays
additionalProperties: false
description: Detail variant of the medication resource enriched with the joined `category`. The base medication fields
are inlined; see the `Medication` component for their semantics.
@@ -41545,6 +41595,18 @@ components:
- type: string
- type: "null"
description: Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.
+ unitsPerDose:
+ anyOf:
+ - type: number
+ - type: "null"
+ description: v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule
+ inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value
+ from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`.
+ resolvedUnitsPerDose:
+ type: number
+ description: v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose
+ ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never
+ re-derive the inheritance rule.
daysOfWeek:
anyOf:
- type: string
@@ -41609,6 +41671,8 @@ components:
- windowEnd
- label
- dose
+ - unitsPerDose
+ - resolvedUnitsPerDose
- daysOfWeek
- timesOfDay
- reminderGraceMinutes
diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts
index e32baa702..c9f76d0c5 100644
--- a/src/app/api/medications/[id]/glp1/route.ts
+++ b/src/app/api/medications/[id]/glp1/route.ts
@@ -110,7 +110,12 @@ export const GET = apiHandler(
prefsRow?.notificationPrefs ?? null,
medication.reorderLeadDays,
);
- const schedules: RunwaySchedule[] = medication.schedules;
+ const schedules: RunwaySchedule[] = medication.schedules.map((sched) => ({
+ ...sched,
+ // Decimal → number so the slot-aware runway math reads the wire shape.
+ unitsPerDose:
+ sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
+ }));
/** runway ≤ effective trigger AND the alert is enabled. */
const isLowStock = (dosesRemaining: number): boolean => {
if (runwayFloor === null) return false;
diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts
index 6dd257d6e..97be7fbd8 100644
--- a/src/app/api/medications/[id]/route.ts
+++ b/src/app/api/medications/[id]/route.ts
@@ -33,6 +33,10 @@ import {
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
+import {
+ effectiveUnitsPerDose,
+ estimateUnitsRunwayDays,
+} from "@/components/medications/detail/supply-runway";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import { getUserTodayBounds } from "@/lib/tz/local-day";
@@ -177,13 +181,59 @@ export const GET = apiHandler(
},
});
+ // v1.37.19 — stock + runway on the detail wire, mirroring the list
+ // payload's semantics: NULL stock = inventory tracking off, 0 = the
+ // supply ran out; runwayDays via the slot-aware burn rate. Published
+ // resolved so no client re-derives the inheritance / cadence math.
+ const [usableStock, anyItemCount] = await Promise.all([
+ prisma.medicationInventoryItem.aggregate({
+ where: {
+ userId: user.id,
+ medicationId: id,
+ state: { in: ["ACTIVE", "IN_USE"] },
+ unitsRemaining: { gt: 0 },
+ },
+ _sum: { unitsRemaining: true },
+ }),
+ prisma.medicationInventoryItem.count({
+ where: { userId: user.id, medicationId: id },
+ }),
+ ]);
+ const schedulesDto = serializeScheduleUnitsPerDose(
+ medication.schedules,
+ medication.unitsPerDose,
+ );
+ const stockUnitsRemaining =
+ anyItemCount > 0 ? Number(usableStock._sum.unitsRemaining ?? 0) : null;
+ const stockDosesRemaining =
+ stockUnitsRemaining === null
+ ? null
+ : Math.floor(
+ stockUnitsRemaining /
+ effectiveUnitsPerDose(
+ schedulesDto,
+ Number(medication.unitsPerDose),
+ ),
+ );
+ const runwayDays =
+ stockUnitsRemaining === null
+ ? null
+ : estimateUnitsRunwayDays(
+ stockUnitsRemaining,
+ schedulesDto,
+ Number(medication.unitsPerDose),
+ );
+
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
- schedules: serializeScheduleUnitsPerDose(medication.schedules),
+ schedules: schedulesDto,
category,
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
+ stockUnitsRemaining,
+ stockDosesRemaining,
+ runwayDays,
});
},
);
@@ -836,7 +886,10 @@ export const PUT = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
- schedules: serializeScheduleUnitsPerDose(medication.schedules),
+ schedules: serializeScheduleUnitsPerDose(
+ medication.schedules,
+ medication.unitsPerDose,
+ ),
category: normalizedCategory,
});
},
diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts
index 4a90d9936..1c51f2860 100644
--- a/src/app/api/medications/route.ts
+++ b/src/app/api/medications/route.ts
@@ -95,7 +95,10 @@ async function respondWithExistingMirror(
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
- schedules: serializeScheduleUnitsPerDose(medication.schedules),
+ schedules: serializeScheduleUnitsPerDose(
+ medication.schedules,
+ Number(medication.unitsPerDose),
+ ),
category,
});
}
@@ -413,7 +416,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
- schedules: serializeScheduleUnitsPerDose(medication.schedules),
+ schedules: serializeScheduleUnitsPerDose(
+ medication.schedules,
+ medication.unitsPerDose,
+ ),
category: normalizedCategory,
},
201,
diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts
index 77ea502d7..fc8deee61 100644
--- a/src/components/medications/__tests__/supply-runway.test.ts
+++ b/src/components/medications/__tests__/supply-runway.test.ts
@@ -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();
+ });
+});
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index 5e4d35a3a..905cb3b30 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -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 (
diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts
index bbc36a58f..77c58b4a5 100644
--- a/src/components/medications/detail/supply-runway.ts
+++ b/src/components/medications/detail/supply-runway.ts
@@ -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).
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index 3f270f4f1..a5499c887 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -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
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index afb30fd04..1c8f2f839 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -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
diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx
index 2e9269c30..a278b3c60 100644
--- a/src/components/medications/medication-table.tsx
+++ b/src/components/medications/medication-table.tsx
@@ -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
diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts
index 627ffdb99..493183833 100644
--- a/src/lib/jobs/medication-low-stock.ts
+++ b/src/lib/jobs/medication-low-stock.ts
@@ -57,6 +57,7 @@
import type { PrismaClient } from "@/generated/prisma/client";
import {
classifyLowStockState,
+ effectiveUnitsPerDose,
estimateDailyDoseCount,
estimateRunwayDays,
lowStockTriggerDays,
@@ -125,7 +126,12 @@ export function evaluateMedicationRunway(
unitsPerDose: number,
schedules: RunwaySchedule[],
): LowStockEvaluation {
- const supply = summariseSupply(items, unitsPerDose);
+ // v1.37.19 — divide the pool by the schedule-weighted average units per
+ // dose, so a per-slot medication's doses/runway match the wire figures.
+ const supply = summariseSupply(
+ items,
+ effectiveUnitsPerDose(schedules, unitsPerDose),
+ );
// v1.18.11 (#31) — server-side observability for the central stock clamp.
// `summariseSupply` floors a corrupt/legacy negative pool to zero; this is
// the request-scoped seam that records the underflow so the data defect
@@ -411,6 +417,8 @@ export async function runMedicationLowStockTick(
timesOfDay: true,
rrule: true,
rollingIntervalDays: true,
+ // v1.37.19 — per-slot units feed the slot-aware burn rate.
+ unitsPerDose: true,
},
},
},
@@ -419,6 +427,16 @@ export async function runMedicationLowStockTick(
for (const med of medications) {
summary.medicationsEvaluated += 1;
+ // Decimal → number for the per-slot units so the runway math and
+ // the wire's `runwayDays` read the identical shape.
+ const runwaySchedules: RunwaySchedule[] = med.schedules.map(
+ (sched) => ({
+ ...sched,
+ unitsPerDose:
+ sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
+ }),
+ );
+
const evaluation = evaluateMedicationRunway(
// v1.16.12 — Decimal columns → JS numbers for the runway math.
med.inventoryItems.map((it) => ({
@@ -427,7 +445,7 @@ export async function runMedicationLowStockTick(
unitsRemaining: Number(it.unitsRemaining),
})),
Number(med.unitsPerDose),
- med.schedules,
+ runwaySchedules,
);
// v1.17.0 — effective reorder lead (per-med override beats the
@@ -441,7 +459,7 @@ export async function runMedicationLowStockTick(
const triggerDays = lowStockTriggerDays({
lowStockRunwayDays: thresholdDays,
leadDays,
- schedules: med.schedules,
+ schedules: runwaySchedules,
});
const decision = decideLowStockAction({
@@ -494,7 +512,7 @@ export async function runMedicationLowStockTick(
expiredUnits: evaluation.expiredUnits,
leadDays,
triggerDays,
- schedules: med.schedules,
+ schedules: runwaySchedules,
today: now,
});
const renderForRecipient = (locale: Locale) => {
@@ -506,7 +524,7 @@ export async function runMedicationLowStockTick(
expiredUnits: evaluation.expiredUnits,
leadDays,
triggerDays,
- schedules: med.schedules,
+ schedules: runwaySchedules,
today: now,
});
return { title: rendered.title, message: rendered.body };
diff --git a/src/lib/medications/__tests__/schedule-units-dto.test.ts b/src/lib/medications/__tests__/schedule-units-dto.test.ts
new file mode 100644
index 000000000..fa7b457bd
--- /dev/null
+++ b/src/lib/medications/__tests__/schedule-units-dto.test.ts
@@ -0,0 +1,41 @@
+/**
+ * #219 / iOS #25 — the per-schedule units wire shape.
+ *
+ * Watched red: with `resolvedUnitsPerDose` falling back to the raw value
+ * alone (no medication-level inheritance) the NULL-slot case below fails —
+ * the pre-v1.37.19 wire shipped only the raw nullable column and told the
+ * client to derive the inheritance itself.
+ */
+import { describe, expect, it } from "vitest";
+
+import { Prisma } from "@/generated/prisma/client";
+
+import { serializeScheduleUnitsPerDose } from "../schedule-units-dto";
+
+describe("serializeScheduleUnitsPerDose", () => {
+ it("keeps the raw column and adds the server-resolved effective value", () => {
+ const [explicit, inherited] = serializeScheduleUnitsPerDose(
+ [
+ { id: "s1", unitsPerDose: new Prisma.Decimal("0.5") },
+ { id: "s2", unitsPerDose: null },
+ ],
+ new Prisma.Decimal("2"),
+ );
+ // Explicit slot: raw stays, resolved equals it.
+ expect(explicit.unitsPerDose).toBe(0.5);
+ expect(explicit.resolvedUnitsPerDose).toBe(0.5);
+ // Inheriting slot: raw stays NULL (the edit surface needs the
+ // distinction), resolved carries the medication level.
+ expect(inherited.unitsPerDose).toBeNull();
+ expect(inherited.resolvedUnitsPerDose).toBe(2);
+ });
+
+ it("serialises Decimals to plain JSON numbers", () => {
+ const [row] = serializeScheduleUnitsPerDose(
+ [{ unitsPerDose: new Prisma.Decimal("0.3333") }],
+ 1,
+ );
+ expect(typeof row.unitsPerDose).toBe("number");
+ expect(typeof row.resolvedUnitsPerDose).toBe("number");
+ });
+});
diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts
index cbc190833..aff75744c 100644
--- a/src/lib/medications/list-read.ts
+++ b/src/lib/medications/list-read.ts
@@ -15,6 +15,10 @@
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { getMedicationCategories } from "@/lib/medication-category";
+import {
+ effectiveUnitsPerDose,
+ estimateUnitsRunwayDays,
+} from "@/components/medications/detail/supply-runway";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import {
computeDisplayDue,
@@ -247,17 +251,42 @@ export async function buildMedicationsList(
const stockUnitsRemaining = tracksInventory
? (usableUnitsByMedId.get(m.id) ?? 0)
: null;
+ // #219 / iOS #25 — schedules on the wire carry both the raw nullable
+ // per-slot units AND the server-resolved effective value.
+ const schedulesDto = serializeScheduleUnitsPerDose(
+ m.schedules,
+ m.unitsPerDose,
+ );
+ // v1.37.19 — slot-aware doses figure: divide the units pool by the
+ // schedule-weighted average units per dose, not the medication-level
+ // column alone (wrong for any per-slot medication). Falls back to the
+ // medication level when no schedule derives a consumption rate.
const stockDosesRemaining =
stockUnitsRemaining === null
? null
- : Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
+ : Math.floor(
+ stockUnitsRemaining /
+ effectiveUnitsPerDose(schedulesDto, Number(m.unitsPerDose)),
+ );
+ // v1.37.19 — projected runway in whole days on the wire (the low-stock
+ // engine's burn-rate math, slot-aware). NULL when inventory tracking is
+ // off or no schedule derives a consumption rate; 0 when the supply ran
+ // out. Published resolved so no client re-derives the cadence math.
+ const runwayDays =
+ stockUnitsRemaining === null
+ ? null
+ : estimateUnitsRunwayDays(
+ stockUnitsRemaining,
+ schedulesDto,
+ Number(m.unitsPerDose),
+ );
return {
...m,
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
// the string Prisma would otherwise serialise a Decimal to.
unitsPerDose: Number(m.unitsPerDose),
// #219 — same Decimal → number unwrap for the per-schedule column.
- schedules: serializeScheduleUnitsPerDose(m.schedules),
+ schedules: schedulesDto,
category: categoryMap[m.id] ?? "OTHER",
// v1.32.25 — provenance echo. Surfacing the mirror source lets the
// web UI and an operator tell an externally-mirrored row (today only
@@ -273,6 +302,7 @@ export async function buildMedicationsList(
nextDueScheduleId: display?.scheduleId ?? null,
stockUnitsRemaining,
stockDosesRemaining,
+ runwayDays,
};
});
}
diff --git a/src/lib/medications/schedule-units-dto.ts b/src/lib/medications/schedule-units-dto.ts
index 6aa8b694c..986bceeae 100644
--- a/src/lib/medications/schedule-units-dto.ts
+++ b/src/lib/medications/schedule-units-dto.ts
@@ -8,6 +8,14 @@
* column: convert to a number, keep NULL as NULL (NULL means "inherit the
* medication level"). One helper so the five medication read/write responses
* cannot drift on the shape.
+ *
+ * v1.37.19 (iOS #25 parity) — every schedule additionally carries
+ * `resolvedUnitsPerDose`: the EFFECTIVE units this slot consumes, resolved
+ * server-side (`schedule.unitsPerDose ?? medication.unitsPerDose`, matching
+ * the consumption resolver's >0 guard). Publish the resolved value; no
+ * client re-derives the inheritance rule. The raw nullable `unitsPerDose`
+ * stays beside it because the edit surface must distinguish "explicit"
+ * from "inherits".
*/
import type { Prisma } from "@/generated/prisma/client";
@@ -15,10 +23,21 @@ type ScheduleWithUnits = { unitsPerDose: Prisma.Decimal | null };
export function serializeScheduleUnitsPerDose(
schedules: readonly T[],
-): Array & { unitsPerDose: number | null }> {
- return schedules.map((schedule) => ({
- ...schedule,
- unitsPerDose:
- schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose),
- }));
+ medicationUnitsPerDose: Prisma.Decimal | number,
+): Array<
+ Omit & {
+ unitsPerDose: number | null;
+ resolvedUnitsPerDose: number;
+ }
+> {
+ const fallback = Number(medicationUnitsPerDose);
+ return schedules.map((schedule) => {
+ const raw =
+ schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose);
+ return {
+ ...schedule,
+ unitsPerDose: raw,
+ resolvedUnitsPerDose: raw !== null && raw > 0 ? raw : fallback,
+ };
+ });
}
diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts
index 26dbf0f87..a5cb26b1d 100644
--- a/src/lib/openapi/routes/medications/schemas.ts
+++ b/src/lib/openapi/routes/medications/schemas.ts
@@ -68,6 +68,17 @@ export const medicationScheduleResource = z
.describe(
"Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.",
),
+ unitsPerDose: z
+ .number()
+ .nullable()
+ .describe(
+ "v1.37.10 (#219) — per-slot inventory-units override (may be a split-pill fraction). NULL means the schedule inherits the medication-level `unitsPerDose`. Kept raw so an edit surface can distinguish an explicit value from inheritance; consumers wanting the effective figure read `resolvedUnitsPerDose`.",
+ ),
+ resolvedUnitsPerDose: z
+ .number()
+ .describe(
+ "v1.37.19 — the EFFECTIVE units one dose of this slot consumes, resolved server-side (`schedule.unitsPerDose ?? medication.unitsPerDose`, matching the intake consumption resolver). Always present; clients never re-derive the inheritance rule.",
+ ),
daysOfWeek: z
.string()
.nullable()
@@ -260,7 +271,14 @@ export const medicationListEntry = medicationResource
.int()
.nullable()
.describe(
- "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.",
+ "v1.16.10 — dose-derived stock as a whole-dose count. v1.37.19 — slot-aware: the divisor is the schedule-weighted average units per dose (each slot's `resolvedUnitsPerDose` weighted by its cadence share), falling back to the medication-level `unitsPerDose` when no schedule derives a consumption rate. `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.",
+ ),
+ runwayDays: z
+ .number()
+ .int()
+ .nullable()
+ .describe(
+ "v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored.",
),
})
.meta({
@@ -272,6 +290,26 @@ export const medicationListEntry = medicationResource
export const medicationDetailEntry = medicationResource
.extend({
category: medicationCategoryEnum,
+ stockUnitsRemaining: z
+ .number()
+ .nullable()
+ .describe(
+ "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory tracking off; 0 = tracking on, supply ran out.",
+ ),
+ stockDosesRemaining: z
+ .number()
+ .int()
+ .nullable()
+ .describe(
+ "v1.37.19 — slot-aware dose-derived stock, mirroring the list entry's field.",
+ ),
+ runwayDays: z
+ .number()
+ .int()
+ .nullable()
+ .describe(
+ "v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off or no consuming cadence.",
+ ),
})
.meta({
id: "MedicationDetail",