mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Publish the resolved per-slot dose units and a slot-aware runway on the medication wire
A medication whose slots carry different doses (a whole tablet in the morning, a half at noon — #219) was consumed correctly but reported wrongly: the wire shipped only the raw nullable per-slot column (clients had to derive the inheritance), stockDosesRemaining divided by the medication-level units alone, and no runway figure existed outside the low-stock push. - Every schedule now carries resolvedUnitsPerDose, resolved server-side exactly like the intake consumption resolver. - stockDosesRemaining divides the units pool by the schedule-weighted average units per dose. - runwayDays (whole days of supply under the slot-aware burn rate) rides the list AND detail payloads; the detail GET also gains the stock aggregates the list already had. - The low-stock engine runs the same slot-aware math, so the push and the wire cannot disagree; the web cards, table, and detail Übersicht prefer the server figure over their client estimate. - OpenAPI regenerated with the new fields.
This commit is contained in:
1 parent
542ca91be5
commit
4b009c3a45
15 files changed
+510
-85
No files matched your search
+66
-2
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user