mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(medications): make inventory Bestand summary server-authoritative
The medication detail clients computed the supply headline in the browser via summariseSupply, risking web/iOS drift and pulling the shared math into the client bundle. The GET /api/medications/[id]/inventory route now computes the canonical SupplySummary server-side through summariseSupply (the single source of truth) and ships it in the DTO; the Bestand and Übersicht surfaces render the ready figures. Adds the MedicationSupplySummary schema to the inventory list response and regenerates the OpenAPI contract.
This commit is contained in:
1 parent
7540d0b508
commit
2386d1fe78
10 files changed
+292
-60
No files matched your search
@@ -7,12 +7,19 @@
|
||||
* suffix — matching the list payload and the GLP-1 endpoint instead of
|
||||
* counting expired stock as supply.
|
||||
*
|
||||
* v1.19.0 (iOS#25) — the headline is now server-authoritative: the GET
|
||||
* response carries a `summary` computed server-side via `summariseSupply`,
|
||||
* and the component RENDERS it (no client-side derivation). The seed
|
||||
* builds that summary with the same canonical helper the server uses, so
|
||||
* these scenarios assert the component renders the server DTO faithfully.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
|
||||
@@ -35,9 +42,27 @@ function render(node: React.ReactNode): string {
|
||||
);
|
||||
}
|
||||
|
||||
function seedItems(items: unknown[]) {
|
||||
function seedItems(
|
||||
items: Array<{
|
||||
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
[key: string]: unknown;
|
||||
}>,
|
||||
unitsPerDose = 1,
|
||||
) {
|
||||
// The server computes the canonical summary; reproduce it here with the
|
||||
// one source of truth so the seed mirrors the real DTO the route ships.
|
||||
const summary = summariseSupply(
|
||||
items.map((i) => ({
|
||||
state: i.state,
|
||||
unitsTotal: i.unitsTotal,
|
||||
unitsRemaining: i.unitsRemaining,
|
||||
})),
|
||||
unitsPerDose,
|
||||
);
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { items, meta: { total: items.length } },
|
||||
data: { items, summary, meta: { total: items.length } },
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ import { SideEffectsSection } from "@/components/medications/side-effects-sectio
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { TitrationTimeline } from "@/components/medications/titration-timeline";
|
||||
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
|
||||
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
||||
import type { SupplySummary } from "@/lib/medications/inventory/summary";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -335,9 +335,8 @@ export function MedicationDetailTabs({
|
||||
return await apiGet<{
|
||||
items: Array<{
|
||||
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
unitsTotal: number;
|
||||
unitsRemaining: number;
|
||||
}>;
|
||||
summary?: SupplySummary;
|
||||
} | null>(`/api/medications/${id}/inventory`);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -348,26 +347,34 @@ export function MedicationDetailTabs({
|
||||
});
|
||||
|
||||
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
|
||||
// v1.16.10 — items count UNITS; the supply readout and the runway are
|
||||
// dose-derived (floor over the pooled units — consumption spills
|
||||
// across containers, so the pool is the honest dose count). The raw
|
||||
// unit tally renders as secondary text when a dose spans > 1 unit.
|
||||
// Availability rides the shared summary helper (ACTIVE / IN_USE with
|
||||
// units only — the list / GLP-1 semantic); expired stock surfaces as
|
||||
// a separate muted suffix and never feeds the runway.
|
||||
// v1.16.12 — guard at > 0, NOT ≥ 1, so a fractional unitsPerDose (½
|
||||
// tablet per dose) stays fractional instead of halving the dose counts.
|
||||
const perDose =
|
||||
medication.unitsPerDose && medication.unitsPerDose > 0
|
||||
? medication.unitsPerDose
|
||||
: 1;
|
||||
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
|
||||
// GET response carries a `summary` computed server-side through the
|
||||
// shared `summariseSupply` helper (ACTIVE / IN_USE with units only —
|
||||
// the list / GLP-1 semantic; EXPIRED surfaced separately, never
|
||||
// available; dose-derived counts floored over the pooled units). The
|
||||
// Übersicht status row renders these ready figures, so web and iOS read
|
||||
// identical numbers from the same DTO. The runway estimate is a UI
|
||||
// projection over the canonical `dosesRemaining`.
|
||||
const {
|
||||
unitsRemaining,
|
||||
unitsTotal,
|
||||
dosesRemaining,
|
||||
dosesTotal,
|
||||
expiredUnits,
|
||||
} = summariseSupply(inventoryItems, perDose);
|
||||
} = inventory?.summary ?? {
|
||||
unitsRemaining: 0,
|
||||
unitsTotal: 0,
|
||||
dosesRemaining: 0,
|
||||
dosesTotal: 0,
|
||||
expiredUnits: 0,
|
||||
};
|
||||
// v1.16.12 — fractional unitsPerDose (½ tablet per dose) drives the
|
||||
// "show the raw unit tally" toggle below; the canonical figures above
|
||||
// already account for it server-side.
|
||||
const perDose =
|
||||
medication.unitsPerDose && medication.unitsPerDose > 0
|
||||
? medication.unitsPerDose
|
||||
: 1;
|
||||
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
||||
import type { SupplySummary } from "@/lib/medications/inventory/summary";
|
||||
|
||||
/**
|
||||
* Invalidate every read key whose payload reflects a medication's supply
|
||||
@@ -127,6 +127,11 @@ interface InventoryItem {
|
||||
|
||||
interface InventoryResponse {
|
||||
items: InventoryItem[];
|
||||
// v1.19.0 (iOS#25) — server-authoritative supply summary. The headline
|
||||
// figures are computed server-side via the shared `summariseSupply`
|
||||
// helper and shipped ready; the client renders them rather than
|
||||
// re-deriving in the browser, so web and iOS agree on the Bestand.
|
||||
summary?: SupplySummary;
|
||||
meta?: { total: number };
|
||||
}
|
||||
|
||||
@@ -232,28 +237,19 @@ export function InventorySection({
|
||||
}
|
||||
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
// Available supply: ACTIVE / IN_USE containers with units left — the
|
||||
// shared summary helper keeps this row, the Übersicht row, the list
|
||||
// payload and the GLP-1 endpoint on one predicate. Expired stock is
|
||||
// never available; it renders as a separate muted suffix.
|
||||
// Dose-derived headline (floor — a partial dose is not a dose).
|
||||
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
|
||||
// the available-supply math; it shows "—" in its own row but cannot pad
|
||||
// the pooled headline. Coalesce null → 0 only for the summary inputs.
|
||||
const {
|
||||
unitsRemaining: remainingUnits,
|
||||
unitsTotal: totalUnits,
|
||||
dosesRemaining: remaining,
|
||||
dosesTotal: total,
|
||||
expiredUnits,
|
||||
} = summariseSupply(
|
||||
items.map((item) => ({
|
||||
state: item.state,
|
||||
unitsTotal: item.unitsTotal ?? 0,
|
||||
unitsRemaining: item.unitsRemaining ?? 0,
|
||||
})),
|
||||
perDose,
|
||||
);
|
||||
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
|
||||
// GET response carries a `summary` computed server-side through the
|
||||
// shared `summariseSupply` helper (ACTIVE / IN_USE with units left;
|
||||
// EXPIRED surfaced separately, never available; dose-derived counts
|
||||
// floored). The client renders these ready figures so web and iOS show
|
||||
// identical numbers. The per-item rows below still divide by `perDose`
|
||||
// locally, which is presentation only — the canonical pool is the DTO.
|
||||
const summary = data?.summary;
|
||||
const remainingUnits = summary?.unitsRemaining ?? 0;
|
||||
const totalUnits = summary?.unitsTotal ?? 0;
|
||||
const remaining = summary?.dosesRemaining ?? 0;
|
||||
const total = summary?.dosesTotal ?? 0;
|
||||
const expiredUnits = summary?.expiredUnits ?? 0;
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
|
||||
Reference in new issue
Block a user