diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index abb539733..f4695468d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2620,7 +2620,10 @@ paths: - Medications summary: List a medication's supply containers description: Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then - `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. + `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. v1.19.0 + (iOS#25) — also returns a server-computed `summary` (the canonical {`unitsRemaining`, `unitsTotal`, + `dosesRemaining`, `dosesTotal`, `expiredUnits`}); clients render it directly rather than re-deriving the Bestand + headline, so web and iOS agree. parameters: - in: path name: id @@ -12794,6 +12797,8 @@ components: type: array items: $ref: "#/components/schemas/MedicationInventoryItem" + summary: + $ref: "#/components/schemas/MedicationSupplySummary" meta: type: object properties: @@ -12806,6 +12811,7 @@ components: additionalProperties: false required: - items + - summary - meta additionalProperties: false error: @@ -12922,6 +12928,36 @@ components: description: One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock. + MedicationSupplySummary: + type: object + properties: + unitsRemaining: + type: number + description: Pooled units across available (ACTIVE / IN_USE, units left) containers. Floored at 0 — a corrupt / legacy + negative row can never surface a negative Bestand. + unitsTotal: + type: number + description: Pooled capacity across the same available containers. + dosesRemaining: + type: number + description: "Dose-derived headline: `floor(unitsRemaining / unitsPerDose)` (whole doses; a partial dose is not a dose)." + dosesTotal: + type: number + description: "Dose-derived capacity: `floor(unitsTotal / unitsPerDose)`." + expiredUnits: + type: number + description: Units still sitting in EXPIRED containers — visible to the user as a muted suffix, never folded into the + available headline or the runway estimate. + required: + - unitsRemaining + - unitsTotal + - dosesRemaining + - dosesTotal + - expiredUnits + additionalProperties: false + description: Server-authoritative supply summary for a medication's containers. Computed from the same availability + predicate the medications-list payload and the GLP-1 endpoint use, so every surface agrees on what 'remaining' + means. CreateMedicationInventoryItemResponse: type: object properties: diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index a0b0ea611..78f4f33ed 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -122,6 +122,51 @@ describe("GET /api/medications/[id]/inventory", () => { expect(body.data.items).toHaveLength(2); expect(body.data.meta.total).toBe(2); }); + + // v1.19.0 (iOS#25) — the GET response carries a server-computed supply + // summary so the detail-page clients render the canonical Bestand + // instead of re-deriving it. ACTIVE / IN_USE with units pool in; + // EXPIRED surfaces separately and never counts as available. + it("returns a server-computed supply summary (dose-derived, expired separate)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + unitsPerDose: 2, + } as never); + vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([ + { id: "inv-1", state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + { id: "inv-2", state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + { id: "inv-3", state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, + { id: "inv-4", state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }, + ] as never); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/inventory"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + summary: { + unitsRemaining: number; + unitsTotal: number; + dosesRemaining: number; + dosesTotal: number; + expiredUnits: number; + }; + }; + }; + // 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses; + // 4 units sit in the expired container (visible, never available). + expect(body.data.summary).toEqual({ + unitsRemaining: 7, + unitsTotal: 8, + dosesRemaining: 3, + dosesTotal: 4, + expiredUnits: 4, + }); + }); }); describe("POST /api/medications/[id]/inventory", () => { diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 68978f3c1..a5ce6d0f2 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -30,6 +30,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createInventoryItemSchema } from "@/lib/validations/medication"; import { buildCreateInventoryInput, + buildSupplySummary, serializeInventoryItem, } from "@/lib/medications/inventory/service"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -48,10 +49,27 @@ export const GET = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const items = await prisma.medicationInventoryItem.findMany({ - where: { userId: user.id, medicationId: id }, - orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }], - }); + const [items, medication] = await Promise.all([ + prisma.medicationInventoryItem.findMany({ + where: { userId: user.id, medicationId: id }, + orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }], + }), + prisma.medication.findUnique({ + where: { id }, + select: { unitsPerDose: true }, + }), + ]); + + const serialized = items.map(serializeInventoryItem); + // v1.19.0 (iOS#25) — the canonical supply summary is computed HERE, + // server-side, through the one source of truth (`summariseSupply`). + // The detail-page client renders these ready figures instead of + // re-deriving them in the browser, so web and iOS read identical + // numbers from the same DTO. + const summary = buildSupplySummary( + serialized, + medication?.unitsPerDose ? Number(medication.unitsPerDose) : 1, + ); annotate({ action: { name: "medication.inventory.list" }, @@ -59,7 +77,8 @@ export const GET = apiHandler( }); return apiSuccess({ - items: items.map(serializeInventoryItem), + items: serialized, + summary, meta: { total: items.length }, }); }, diff --git a/src/components/medications/__tests__/inventory-section-summary.test.tsx b/src/components/medications/__tests__/inventory-section-summary.test.tsx index fa6a2001d..599244947 100644 --- a/src/components/medications/__tests__/inventory-section-summary.test.tsx +++ b/src/components/medications/__tests__/inventory-section-summary.test.tsx @@ -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, }); } diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index fd7febc1d..563abe0f5 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -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 ( diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 9a54d11f9..5b959a88c 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -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 = ( <> diff --git a/src/lib/medications/inventory/__tests__/summary.test.ts b/src/lib/medications/inventory/__tests__/summary.test.ts index edf6bcfb4..ae5b930ce 100644 --- a/src/lib/medications/inventory/__tests__/summary.test.ts +++ b/src/lib/medications/inventory/__tests__/summary.test.ts @@ -140,32 +140,57 @@ describe("summariseSupply / detectSupplyUnderflow — central negative-stock san }); }); -describe("supply surfaces — both detail-page readouts ride the shared helper (source guard)", () => { +describe("supply surfaces — server computes, the detail-page clients render the DTO (source guard)", () => { + // v1.19.0 (iOS#25) — server-authoritative parity. The detail-page + // clients used to call `summariseSupply` in the browser; that risked + // web ↔ iOS drift and dragged the shared math into the client bundle. + // The SERVER now computes the canonical summary via the one source of + // truth and ships it in the DTO; the clients render it. This guard is + // re-pointed at the new seam: (1) the GET inventory route computes the + // summary through `summariseSupply`, (2) neither client imports the + // helper as a value, (3) both consume the server `summary` field. const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); - it("the Übersicht supply row sums through summariseSupply and feeds the runway with the available-only figure", () => { + it("the SERVER computes the summary through summariseSupply (single source of truth)", () => { + const service = read("src/lib/medications/inventory/service.ts"); + expect(service).toContain('from "./summary"'); + expect(service).toMatch(/summariseSupply\(/); + expect(service).toContain("buildSupplySummary"); + + const route = read("src/app/api/medications/[id]/inventory/route.ts"); + // The GET response carries the server-computed summary in the DTO. + expect(route).toContain("buildSupplySummary"); + expect(route).toMatch(/summary,/); + }); + + it("the Übersicht client renders the server summary and never imports the helper as a value", () => { const src = read( "src/components/medications/detail/medication-detail-tabs.tsx", ); - expect(src).toContain('from "@/lib/medications/inventory/summary"'); - expect(src).toMatch(/summariseSupply\(inventoryItems, perDose\)/); + // Type-only import is allowed; a value import of the helper is not. + expect(src).toContain( + 'import type { SupplySummary } from "@/lib/medications/inventory/summary"', + ); + expect(src).not.toMatch(/^import \{[^}]*summariseSupply/m); + // The client consumes the server-provided summary, not a local + // derivation, and still drives the runway with the available figure. + expect(src).toMatch(/inventory\?\.summary/); expect(src).toMatch( /estimateRunwayDays\(dosesRemaining, medication\.schedules\)/, ); - // No hand-rolled "everything but USED_UP" filter remains. expect(src).not.toContain('state !== "USED_UP"'); expect(src).toContain("medications.detail.bestand.expiredSuffix"); }); - it("the Bestand summary sums through summariseSupply and surfaces the expired suffix", () => { + it("the Bestand client renders the server summary and never imports the helper as a value", () => { const src = read( "src/components/medications/sections/inventory-section.tsx", ); - expect(src).toContain('from "@/lib/medications/inventory/summary"'); - // v1.18.3 (iOS#31) — the items are mapped through a nullable→0 coalesce - // (an unknown-units row contributes nothing to the available headline) - // before the shared helper, but the summary still rides summariseSupply. - expect(src).toMatch(/summariseSupply\(\s*items\.map\(/); + expect(src).toContain( + 'import type { SupplySummary } from "@/lib/medications/inventory/summary"', + ); + expect(src).not.toMatch(/^import \{[^}]*summariseSupply/m); + expect(src).toMatch(/data\?\.summary/); expect(src).not.toContain('state !== "USED_UP"'); expect(src).toContain("medications.detail.bestand.expiredSuffix"); }); diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index dd4e8810d..f317b9551 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -14,6 +14,11 @@ import { prisma } from "@/lib/db"; import type { MedicationContainerType } from "@/generated/prisma/client"; import { computeExpiresAt } from "./state-machine"; +import { + summariseSupply, + type SupplyItemState, + type SupplySummary, +} from "./summary"; /** * Daily cron pass — flip any IN_USE rows whose 30-day window has @@ -89,6 +94,43 @@ export function serializeInventoryItem< }; } +/** + * v1.19.0 (iOS#25) — server-authoritative supply summary for the + * inventory list response. The detail page used to compute the Bestand + * headline in the browser (calling `summariseSupply` client-side), which + * both risked web ↔ iOS drift and dragged the shared math into the + * client bundle. The SERVER now computes the canonical + * {@link SupplySummary} via the one source of truth (`summariseSupply`) + * and ships it in the DTO; the client renders the ready figures. + * + * The serialized items carry `unitsTotal` / `unitsRemaining` as + * `number | null` (null = unknown count for a corrupt / legacy row). + * A null contributes nothing to the available pool — coalesce to 0 for + * the summary inputs only, exactly as the former client code did, so the + * surfaced numbers stay identical to today's for healthy data while an + * unknown-count row still cannot pad the headline. + */ +export function buildSupplySummary( + items: ReadonlyArray<{ + state: SupplyItemState; + unitsTotal: number | null; + unitsRemaining: number | null; + }>, + unitsPerDose: number | null | undefined, +): SupplySummary { + // v1.16.12 — guard at > 0, NOT ≥ 1: a fractional unitsPerDose (½ tablet + // per dose) must stay fractional, else the dose-derived counts halve. + const perDose = unitsPerDose && unitsPerDose > 0 ? unitsPerDose : 1; + return summariseSupply( + items.map((item) => ({ + state: item.state, + unitsTotal: item.unitsTotal ?? 0, + unitsRemaining: item.unitsRemaining ?? 0, + })), + perDose, + ); +} + /** * Helper for the POST /[medId]/inventory route. Composes the * Prisma-create with the computed `expiresAt` so the caller doesn't diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 3432882ef..4107f5c8f 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -22,6 +22,7 @@ import { medicationListEntry, medicationDetailEntry, medicationInventoryItemResource, + medicationSupplySummaryResource, medicationIntakeEventResource, medicationCadenceResponse, medicationComplianceResponse, @@ -285,7 +286,7 @@ export const medicationPaths: NonNullable = { tags: ["Medications"], summary: "List a medication's supply containers", description: - "Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.", + "Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. v1.19.0 (iOS#25) — also returns a server-computed `summary` (the canonical {`unitsRemaining`, `unitsTotal`, `dosesRemaining`, `dosesTotal`, `expiredUnits`}); clients render it directly rather than re-deriving the Bestand headline, so web and iOS agree.", requestParams: { path: z.object({ id: z.string() }), }, @@ -297,6 +298,7 @@ export const medicationPaths: NonNullable = { schema: dataEnvelope( z.object({ items: z.array(medicationInventoryItemResource), + summary: medicationSupplySummaryResource, meta: z.object({ total: z.number().int().nonnegative() }), }), "ListMedicationInventoryResponse", diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index b726f95fd..e2b06986c 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -319,6 +319,41 @@ export const medicationInventoryItemResource = z "One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.", }); +// v1.19.0 (iOS#25) — server-computed canonical supply summary returned +// alongside the inventory list. Replaces the former client-side +// derivation so web and iOS render identical Bestand figures from one +// DTO. Pools ACTIVE / IN_USE containers with units left; EXPIRED stock +// is surfaced separately and never counts as available. +export const medicationSupplySummaryResource = z + .object({ + unitsRemaining: z + .number() + .describe( + "Pooled units across available (ACTIVE / IN_USE, units left) containers. Floored at 0 — a corrupt / legacy negative row can never surface a negative Bestand.", + ), + unitsTotal: z + .number() + .describe("Pooled capacity across the same available containers."), + dosesRemaining: z + .number() + .describe( + "Dose-derived headline: `floor(unitsRemaining / unitsPerDose)` (whole doses; a partial dose is not a dose).", + ), + dosesTotal: z + .number() + .describe("Dose-derived capacity: `floor(unitsTotal / unitsPerDose)`."), + expiredUnits: z + .number() + .describe( + "Units still sitting in EXPIRED containers — visible to the user as a muted suffix, never folded into the available headline or the runway estimate.", + ), + }) + .meta({ + id: "MedicationSupplySummary", + description: + "Server-authoritative supply summary for a medication's containers. Computed from the same availability predicate the medications-list payload and the GLP-1 endpoint use, so every surface agrees on what 'remaining' means.", + }); + export const medicationIntakeEventResource = z .object({ id: z.string(),