From 4b009c3a45ccd625df8bf55427abf39a4261d593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 05:41:15 +0200 Subject: [PATCH] Publish the resolved per-slot dose units and a slot-aware runway on the medication wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 68 +++++++- src/app/api/medications/[id]/glp1/route.ts | 7 +- src/app/api/medications/[id]/route.ts | 57 ++++++- src/app/api/medications/route.ts | 10 +- .../__tests__/supply-runway.test.ts | 55 +++++++ .../detail/medication-detail-tabs.tsx | 12 +- .../medications/detail/supply-runway.ts | 155 +++++++++++++----- .../medications/glp1-medication-card.tsx | 22 ++- .../medications/medication-card.tsx | 23 ++- .../medications/medication-table.tsx | 12 +- src/lib/jobs/medication-low-stock.ts | 28 +++- .../__tests__/schedule-units-dto.test.ts | 41 +++++ src/lib/medications/list-read.ts | 34 +++- src/lib/medications/schedule-units-dto.ts | 31 +++- src/lib/openapi/routes/medications/schemas.ts | 40 ++++- 15 files changed, 510 insertions(+), 85 deletions(-) create mode 100644 src/lib/medications/__tests__/schedule-units-dto.test.ts 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",