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

+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,