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

+66 -2
View File
@@ -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
+6 -1
View File
@@ -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;
+55 -2
View File
@@ -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,
});
},
+8 -2
View File
@@ -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,
@@ -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
+23 -5
View File
@@ -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 };
@@ -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");
});
});
+32 -2
View File
@@ -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,
};
});
}
+25 -6
View File
@@ -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<T extends ScheduleWithUnits>(
schedules: readonly T[],
): Array<Omit<T, "unitsPerDose"> & { unitsPerDose: number | null }> {
return schedules.map((schedule) => ({
...schedule,
unitsPerDose:
schedule.unitsPerDose === null ? null : Number(schedule.unitsPerDose),
}));
medicationUnitsPerDose: Prisma.Decimal | number,
): Array<
Omit<T, "unitsPerDose"> & {
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,
};
});
}
+39 -1
View File
@@ -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",