diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index 25ae4eccd..0b8474349 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -55,11 +55,11 @@ import { type DoseEvent, } from "@/lib/medications/glp1-pk"; import { - GLP1_DRUGS, - findDrugByBrand, + findDrugIdByBrand, type Glp1DrugId, - type Glp1DrugRecord, } from "@/lib/medications/glp1-knowledge"; +import { parseDoseMg } from "@/lib/medications/dose-string"; +import type { ResearchModeStatus } from "@/lib/medications/research-mode-types"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; const CHART_COLOR = "var(--dracula-purple)"; @@ -72,13 +72,6 @@ const WINDOW_HOURS_BEFORE = 21 * HOURS_PER_DAY; const WINDOW_HOURS_AFTER = 0; const SAMPLE_STEP_HOURS = 6; -interface ResearchModeStatus { - enabled: boolean; - acknowledgedAt: string | null; - acknowledgedVersion: string | null; - currentDisclaimerVersion: string; -} - interface IntakeEvent { id: string; takenAt: string | null; @@ -124,20 +117,13 @@ export interface DrugLevelChartProps { export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { const { t } = useTranslations(); - const drugRecord = useMemo( - () => findDrugByBrand(medication.name), + // Resolve the catalog key via the shared helper. v1.4.25 W21 Fix-N + // hoisted `findDrugIdByBrand` so this chart and the titration route + // share one implementation. + const drugId: Glp1DrugId | null = useMemo( + () => findDrugIdByBrand(medication.name), [medication.name], ); - // The catalog keys records by Glp1DrugId; findDrugByBrand returns the - // record value, not the key. Reverse-lookup so we can pass the id to - // the PK helper + the i18n key below. - const drugId: Glp1DrugId | null = useMemo(() => { - if (!drugRecord) return null; - for (const [id, record] of Object.entries(GLP1_DRUGS)) { - if (record === drugRecord) return id as Glp1DrugId; - } - return null; - }, [drugRecord]); const { data: researchMode, isLoading: rmLoading } = useQuery({ @@ -514,17 +500,13 @@ function ChartBody({ * ──────────────────────────────────────────────────────────────── */ /** - * Parse a dose string like "7.5 mg" → 7.5. Returns NaN when the - * string doesn't carry a recognisable number (e.g. "as needed"). - * - * Exported for the unit test. + * Re-export the shared parser so the component-test file + * (which historically imported `parseDoseMg` from this surface) + * keeps working without coupling to the helper module path. v1.4.25 + * W21 Fix-N — single implementation now lives in + * `src/lib/medications/dose-string.ts`. */ -export function parseDoseMg(input: string): number { - if (!input) return Number.NaN; - const match = input.match(/([0-9]+(?:[.,][0-9]+)?)/); - if (!match) return Number.NaN; - return Number.parseFloat(match[1].replace(",", ".")); -} +export { parseDoseMg }; /** * Resolve the dose in mg that applied at `takenAt` by walking the diff --git a/src/components/medications/SchedulingSection.tsx b/src/components/medications/SchedulingSection.tsx index 6fadb30bd..28ae712f6 100644 --- a/src/components/medications/SchedulingSection.tsx +++ b/src/components/medications/SchedulingSection.tsx @@ -8,13 +8,14 @@ import { Loader2, BellRing, BellOff, Pencil } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; /** * v1.4.25 W19e — GLP-1 cadence + compliance section. * * Sits between `` (W19d) and `` - * on the medication detail page. Same chrome as the W19d section so - * the three Wave-4b panels read as one visual group. + * on the medication detail page. Same chrome as the W19d / W19f + * sections, composed via the shared `` wrapper. * * Three sub-sections, top-to-bottom: * @@ -27,6 +28,9 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; * 2. Cadence visualisation — a 30-day track showing one cell per * expected dose, status-coloured (taken / skipped / missed / * upcoming). The legend below the track names each status. + * v1.4.25 W21 Fix-N — each cell is wrapped in a 44×44 px button + * to meet the WCAG 2.5.5 / Apple HIG tap-target rule; the + * visible cell remains the 12 px square the design system pins. * * 3. Compliance chips — four monochrome chips: adherence rate %, * current streak, longest streak, missed last 30. Marc-memory: @@ -108,182 +112,183 @@ export function SchedulingSection({ return `${data.chips.adherenceRate}%`; }, [data, t]); + const headerExtras = ( +
+ + {reminderEnabled ? ( + + ) : ( + + )} + {t( + reminderEnabled + ? "medications.scheduling.reminders.on" + : "medications.scheduling.reminders.off", + )} + + +
+ ); + return ( -
-
-

- {t("medications.scheduling.section")} -

-
- - {reminderEnabled ? ( - - ) : ( - - )} - {t( - reminderEnabled - ? "medications.scheduling.reminders.on" - : "medications.scheduling.reminders.off", - )} - - + {isLoading && ( +
+ + {t("medications.scheduling.loading")}
-
+ )} -
- {isLoading && ( -
- - {t("medications.scheduling.loading")} -
- )} + {!!error && !isLoading && ( +

+ {t("medications.scheduling.loadFailed")} +

+ )} - {!!error && !isLoading && ( -

- {t("medications.scheduling.loadFailed")} -

- )} - - {data && !isLoading && ( -
- {/* Next-dose chip */} -
-

- {data.next ? ( - <> - {t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "} - - {fmt.dateShort(new Date(data.next.windowStart))} - - - ) : ( - t("medications.scheduling.cadenceViz.noNextDose") - )} -

-
- - {/* 30-day timeline */} -
-

- {t("medications.scheduling.cadenceViz.title")} -

- {data.timeline.length === 0 ? ( -

- {t("medications.scheduling.cadenceViz.emptyState")} -

- ) : ( + {data && !isLoading && ( +
+ {/* Next-dose chip */} +
+

+ {data.next ? ( <> -

- {data.timeline.map((slot, i) => ( - - ))} -
-
- {(["taken", "skipped", "missed", "upcoming"] as const).map( - (s) => ( - - - - {t( - `medications.scheduling.cadenceViz.status.${s}`, - )} - - - ), - )} -
+ {t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "} + + {fmt.dateShort(new Date(data.next.windowStart))} + + ) : ( + t("medications.scheduling.cadenceViz.noNextDose") )} -
+

+
- {/* Compliance chips */} -
-

- {t("medications.scheduling.compliance.title")} + {/* 30-day timeline */} +

+

+ {t("medications.scheduling.cadenceViz.title")} +

+ {data.timeline.length === 0 ? ( +

+ {t("medications.scheduling.cadenceViz.emptyState")}

-
- +
+ {data.timeline.map((slot, i) => { + const slotLabel = `${fmt.dateShort(new Date(slot.windowStart))} — ${t( + `medications.scheduling.cadenceViz.status.${slot.status}`, + )}`; + return ( + + ); + })} +
+
+ {(["taken", "skipped", "missed", "upcoming"] as const).map( + (s) => ( + + + + {t( + `medications.scheduling.cadenceViz.status.${s}`, + )} + + + ), )} - /> - - - -
+
+ + )} +
+ + {/* Compliance chips */} +
+

+ {t("medications.scheduling.compliance.title")} +

+
+ + + +
- )} -
-
+ + )} +
); } diff --git a/src/components/medications/SideEffectsSection.tsx b/src/components/medications/SideEffectsSection.tsx index f69e1f2a0..823b1bae2 100644 --- a/src/components/medications/SideEffectsSection.tsx +++ b/src/components/medications/SideEffectsSection.tsx @@ -14,6 +14,13 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { MedicationSideEffectCategory, @@ -26,6 +33,7 @@ import { entriesByCategory, severityLikertLabel, } from "@/lib/medications/side-effects/taxonomy"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; /** * v1.4.25 W19d — GLP-1 side-effect section for the medication detail @@ -40,6 +48,12 @@ import { * each row showing category badge + entry label + severity chip + * timestamp + delete button + collapsed notes. * + * v1.4.25 W21 Fix-N — wraps the shared `` so + * the three Wave-4b sections share one chrome contract, and swaps the + * native `` so the picker + * matches the rest of the form library (focus rings, keyboard nav, + * theme tokens). + * * Component-tests cover: empty state, category → entry filtering, * submit-and-refetch flow, severity-label translation lookup. */ @@ -122,11 +136,14 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const createMutation = useMutation({ mutationFn: async (payload: { - category: MedicationSideEffectCategory; entry: MedicationSideEffectEntry; severity: number; notes: string | null; }) => { + // v1.4.25 W21 Fix-N (code-M6) — drop client-side `category` + // from the wire payload. The server derives it from the entry + // via `categoryForEntry`. Backwards-compatible with older + // clients during the cut window (route accepts both shapes). const res = await fetch( `/api/medications/${medicationId}/side-effects`, { @@ -188,9 +205,11 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { setFormError(t("medications.sideEffects.errorSeverity")); return; } + // Sanity-check the entry still belongs to the picked category; + // the server derives the category authoritatively from `entry`. + void categoryForEntry(entry); createMutation.mutate( { - category: categoryForEntry(entry), entry, severity, notes: notes.trim() ? notes.trim().slice(0, NOTES_MAX) : null, @@ -207,113 +226,109 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { ); } + const headerExtras = ( + <> + {items.length > 0 && ( + + ({items.length}) + + )} + + + ); + return ( -
-
-

- {t("medications.sideEffects.section")} - {items.length > 0 && ( - - ({items.length}) - - )} -

- -
+ {isLoading && ( +
+ + {t("medications.sideEffects.loading")} +
+ )} -
- {isLoading && ( -
- - {t("medications.sideEffects.loading")} -
- )} + {!isLoading && items.length === 0 && ( +

+ {t("medications.sideEffects.emptyState")} +

+ )} - {!isLoading && items.length === 0 && ( -

- {t("medications.sideEffects.emptyState")} + {!isLoading && items.length > 0 && ( +

+

+ {t("medications.sideEffects.recentTitle")}

- )} - - {!isLoading && items.length > 0 && ( -
-

- {t("medications.sideEffects.recentTitle")} -

-
    - {items.map((row) => ( -
  • -
    -
    - - {t( - `medications.sideEffects.categories.${categoryI18nKey(row.category)}`, - )} - - - {t( - `medications.sideEffects.entries.${entryI18nKey(row.entry)}`, - )} - - - {t( - `medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`, - )} - -
    -

    - {fmt.dateShort(new Date(row.occurredAt))} -

    - {row.notes && ( -

    - {row.notes} -

    - )} +
      + {items.map((row) => ( +
    • +
      +
      + + {t( + `medications.sideEffects.categories.${categoryI18nKey(row.category)}`, + )} + + + {t( + `medications.sideEffects.entries.${entryI18nKey(row.entry)}`, + )} + + + {t( + `medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`, + )} +
      - -
    • - ))} -
    -
    - )} -
+

+ {fmt.dateShort(new Date(row.occurredAt))} +

+ {row.notes && ( +

+ {row.notes} +

+ )} +
+ + + ))} + +
+ )} @@ -330,26 +345,32 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { - + + + + + {SIDE_EFFECT_CATEGORY_ORDER.map((c) => ( + + {t( + `medications.sideEffects.categories.${categoryI18nKey(c)}`, + )} + + ))} + +
@@ -460,6 +481,6 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
-
+
); } diff --git a/src/components/medications/TitrationSection.tsx b/src/components/medications/TitrationSection.tsx index b424cc46e..1a2207d8c 100644 --- a/src/components/medications/TitrationSection.tsx +++ b/src/components/medications/TitrationSection.tsx @@ -5,13 +5,15 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, ExternalLink } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; /** * v1.4.25 W19f — GLP-1 titration-ladder section. * * Sits between `` (W19e) and `` * on the medication detail page. Same chrome as the W19d / W19e - * sections so the Wave-4b panels read as one visual group. + * sections so the Wave-4b panels read as one visual group — chrome is + * now composed via the shared `` wrapper. * * Layout: * - Header: drug INN + "Standard ladder (EMA reference)" @@ -24,6 +26,11 @@ import { useTranslations } from "@/lib/i18n/context"; * ladder is a *reference*, never a prescription (MDR boundary). * - Disclaimer + EMA-source link. * + * v1.4.25 W21 Fix-N — dropped the hand-rolled `templateFill` helper; + * the i18n `t()` function already accepts the `params` bag with + * `{key}` placeholder substitution. One source of truth for the + * template-fill semantics. + * * Data comes from `/api/medications/[id]/titration`; that route owns * the prisma queries and delegates math to the pure ladder helpers. */ @@ -50,16 +57,6 @@ interface TitrationSectionProps { medicationId: string; } -function templateFill( - template: string, - values: Record, -): string { - return template.replace(/\{(\w+)\}/g, (match, key) => { - if (key in values) return String(values[key]); - return match; - }); -} - export function TitrationSection({ medicationId }: TitrationSectionProps) { const { t } = useTranslations(); @@ -85,12 +82,12 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) { if (!data || data.currentStep === null) return null; if (data.weeksOnCurrentStep <= 0) return null; if (data.escalationDue) { - return templateFill(t("medications.titration.escalationDueHint"), { + return t("medications.titration.escalationDueHint", { weeks: data.weeksOnCurrentStep, typical: data.currentStep.typicalWeeks, }); } - return templateFill(t("medications.titration.currentStepDwell"), { + return t("medications.titration.currentStepDwell", { weeks: data.weeksOnCurrentStep, }); }, [data, t]); @@ -98,151 +95,141 @@ export function TitrationSection({ medicationId }: TitrationSectionProps) { const nextStepCaption = useMemo(() => { if (!data) return null; if (!data.nextStep) return null; - return templateFill(t("medications.titration.nextStepCaption"), { + return t("medications.titration.nextStepCaption", { dose: data.nextStep.doseMg, weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks, }); }, [data, t]); - return ( -
-
-

- {t("medications.titration.section")} -

- {data && ( - - {data.drugInn} - - )} -
+ {data.drugInn} + + ) : null; -
- {isLoading && ( -
- - {t("medications.titration.loading")} -
- )} + return ( + + {isLoading && ( +
+ + {t("medications.titration.loading")} +
+ )} - {!!error && !isLoading && ( -

- {t("medications.titration.loadFailed")} + {!!error && !isLoading && ( +

+ {t("medications.titration.loadFailed")} +

+ )} + + {data && !isLoading && ( +
+

+ {t("medications.titration.drugLadderHeader")}

- )} - {data && !isLoading && ( -
-

- {t("medications.titration.drugLadderHeader")} + {hasLadder ? ( +

    + {data.ladder.map((step) => { + const isCurrent = currentIdx === step.stepIndex; + const isPast = + currentIdx !== null && step.stepIndex < currentIdx; + return ( +
  1. + + {t("medications.titration.stepLabel", { + n: step.stepIndex + 1, + })} + + + {step.doseMg} {t("medications.titration.doseUnitMg")} + + + {t("medications.titration.typicalWeeksOnStep", { + weeks: step.typicalWeeks, + })} + + {isCurrent && ( + + {t("medications.titration.youAreHere")} + + )} +
  2. + ); + })} +
+ ) : ( +

+ {t("medications.titration.emptyState")}

+ )} - {hasLadder ? ( -
    - {data.ladder.map((step) => { - const isCurrent = currentIdx === step.stepIndex; - const isPast = - currentIdx !== null && step.stepIndex < currentIdx; - return ( -
  1. - - {templateFill(t("medications.titration.stepLabel"), { - n: step.stepIndex + 1, - })} - - - {step.doseMg} {t("medications.titration.doseUnitMg")} - - - {templateFill( - t("medications.titration.typicalWeeksOnStep"), - { weeks: step.typicalWeeks }, - )} - - {isCurrent && ( - - {t("medications.titration.youAreHere")} - - )} -
  2. - ); - })} -
- ) : ( -

- {t("medications.titration.emptyState")} -

- )} + {data.currentStep === null && hasLadder && ( +

+ {t("medications.titration.nonStandardDose")} +

+ )} - {data.currentStep === null && hasLadder && ( -

- {t("medications.titration.nonStandardDose")} -

- )} + {dwellHint && ( +

+ {dwellHint} +

+ )} - {dwellHint && ( -

- {dwellHint} -

- )} + {nextStepCaption && ( +

{nextStepCaption}

+ )} - {nextStepCaption && ( -

{nextStepCaption}

- )} + {data.nextStep === null && data.currentStep !== null && ( +

+ {t("medications.titration.ceilingMessage")} +

+ )} - {data.nextStep === null && data.currentStep !== null && ( -

- {t("medications.titration.ceilingMessage")} -

- )} - -
-

- {t("medications.titration.disclaimer")} -

- - {t("medications.titration.emaSourceCta")} - -
+
+

+ {t("medications.titration.disclaimer")} +

+ + {t("medications.titration.emaSourceCta")} +
- )} -
-
+ + )} +
); } diff --git a/src/components/medications/inventory-section.tsx b/src/components/medications/inventory-section.tsx index e3c907625..c0910ae1c 100644 --- a/src/components/medications/inventory-section.tsx +++ b/src/components/medications/inventory-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react"; @@ -16,6 +16,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { MedicationInventoryState } from "@/generated/prisma/client"; +import { + DEFAULT_IN_USE_WINDOW_DAYS, + daysRemainingInUse, +} from "@/lib/medications/inventory/state-machine"; /** * v1.4.25 W19b — Inventory disclosure for the GLP-1 medication card. @@ -30,13 +35,19 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; * * The UI deliberately reuses existing primitives — Card, Dialog, * Input, Badge, Button — no new shadcn block. + * + * v1.4.25 W21 Fix-N + * - The local `InventoryState` string-union has been dropped in + * favour of the canonical `MedicationInventoryState` enum from + * the Prisma client. + * - The reimplemented client-side `daysRemainingInUse` has been + * replaced by the widened pure helper from the inventory + * state-machine module; one rule for the deadline math. */ -type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; - interface InventoryItem { id: string; - state: InventoryState; + state: MedicationInventoryState; dosesTotal: number; dosesRemaining: number; firstUseAt: string | null; @@ -54,21 +65,6 @@ interface InventorySectionProps { defaultDosesPerUnit: number | null; } -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -function daysRemainingInUse( - firstUseAt: string | null, - windowDays: number, -): number | null { - if (!firstUseAt) return null; - const deadline = new Date(firstUseAt).getTime() + windowDays * MS_PER_DAY; - const ms = deadline - Date.now(); - if (ms <= 0) return 0; - return Math.floor(ms / MS_PER_DAY); -} - -const IN_USE_WINDOW_DAYS = 30; - export function InventorySection({ medicationId, defaultDosesPerUnit, @@ -211,12 +207,22 @@ export function InventorySection({ (i) => i.state === "EXPIRED" || i.state === "USED_UP", ); + // Snapshot "now" once per render. The disclosure is opt-in (the + // user clicks to expand), so re-rendering on focus events should + // not cause the "days remaining" labels to flicker between values. + // eslint-disable-next-line react-hooks/purity -- intentional one-shot snapshot + const nowMs = useMemo(() => Date.now(), []); + function stateLabel(item: InventoryItem): string { if (item.state === "ACTIVE") { return t("medications.inventory.state.active"); } if (item.state === "IN_USE") { - const d = daysRemainingInUse(item.firstUseAt, IN_USE_WINDOW_DAYS); + const d = daysRemainingInUse( + { firstUseAt: item.firstUseAt }, + nowMs, + DEFAULT_IN_USE_WINDOW_DAYS, + ); return t("medications.inventory.state.inUse", { days: d ?? 0 }); } if (item.state === "EXPIRED") { @@ -226,7 +232,7 @@ export function InventorySection({ } function badgeVariant( - state: InventoryState, + state: MedicationInventoryState, ): "default" | "outline" | "secondary" | "destructive" { if (state === "EXPIRED") return "destructive"; if (state === "USED_UP") return "secondary"; diff --git a/src/components/medications/medication-detail-section.tsx b/src/components/medications/medication-detail-section.tsx new file mode 100644 index 000000000..b4ac1cc14 --- /dev/null +++ b/src/components/medications/medication-detail-section.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { type ReactNode } from "react"; + +/** + * v1.4.25 W21 Fix-N — section wrapper for the three medication-detail + * panels (Titration, Scheduling, SideEffects). The four wave-4b + * sections previously hand-rolled the same chrome — border + header + * row + dotted divider + body padding — and drifted on padding (px-3 + * vs px-3 py-2.5 vs px-3 py-2), border opacity (border-border/60 vs + * /70), and aria wiring (aria-labelledby was inconsistent). + * + * This wrapper locks the contract: + * - `border-border/60 rounded-md border` chrome + * - `px-3 py-2.5` header row with `text-foreground/85 text-sm font-medium` title + * - `border-border/60 border-t px-3 py-3 text-xs` body band + * - `aria-labelledby` wired to a stable per-section id derived from `titleId` + * + * Consumers pass: + * - `titleId` — the DOM id on the heading element. Components owning + * a heading id already (e.g. "titration-heading") pass it through; + * new surfaces should follow the `-heading` convention. + * - `title` — the rendered heading text (already i18n-translated by + * the caller). + * - `headerExtras` — optional right-side header content (e.g. drug + * INN badge, edit CTA, "Add" button). + * - `dataSlot` — optional `data-slot` attribute on the outer section + * so cross-section selectors (Playwright, snapshot tests) stay + * stable. + * - `bodyPaddingY` — escape hatch for sections that need a different + * vertical padding (the side-effects body runs at py-2.5 because + * it owns an inline timeline with its own gap rhythm). + */ +export interface MedicationDetailSectionProps { + titleId: string; + title: ReactNode; + headerExtras?: ReactNode; + children: ReactNode; + dataSlot?: string; + bodyPaddingY?: "py-2.5" | "py-3"; +} + +export function MedicationDetailSection({ + titleId, + title, + headerExtras, + children, + dataSlot, + bodyPaddingY = "py-3", +}: MedicationDetailSectionProps) { + return ( +
+
+

+ {title} +

+ {headerExtras} +
+
+ {children} +
+
+ ); +} diff --git a/src/lib/medications/inventory/__tests__/state-machine.test.ts b/src/lib/medications/inventory/__tests__/state-machine.test.ts index 24fcc8bb9..120347070 100644 --- a/src/lib/medications/inventory/__tests__/state-machine.test.ts +++ b/src/lib/medications/inventory/__tests__/state-machine.test.ts @@ -224,4 +224,32 @@ describe("daysRemainingInUse", () => { }); expect(daysRemainingInUse(item, NOW_MS)).toBe(0); }); + + describe("thin-shape overload (v1.4.25 W21 Fix-N)", () => { + // The medication-detail card calls this with just `{ firstUseAt }` + // because it has already filtered the list to state === "IN_USE" + // before reaching the helper. The thin overload skips the + // state-machine gate. + it("accepts a thin `{ firstUseAt: Date }` shape", () => { + const firstUseAt = new Date(NOW_MS - 5 * MS_PER_DAY); + expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(25); + }); + + it("accepts a thin `{ firstUseAt: string }` ISO shape", () => { + const firstUseAt = new Date(NOW_MS - 10 * MS_PER_DAY).toISOString(); + expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(20); + }); + + it("returns null when the thin firstUseAt is null", () => { + expect(daysRemainingInUse({ firstUseAt: null }, NOW_MS)).toBeNull(); + }); + + it("skips the state-machine gate for the thin form", () => { + // 31 days past the window — the full-view path would return + // null here (state ≠ IN_USE). The thin form returns 0 because + // the caller has already gated on IN_USE. + const firstUseAt = new Date(NOW_MS - 31 * MS_PER_DAY); + expect(daysRemainingInUse({ firstUseAt }, NOW_MS)).toBe(0); + }); + }); }); diff --git a/src/lib/medications/inventory/state-machine.ts b/src/lib/medications/inventory/state-machine.ts index d49a4ca5c..4b87f5c19 100644 --- a/src/lib/medications/inventory/state-machine.ts +++ b/src/lib/medications/inventory/state-machine.ts @@ -207,17 +207,36 @@ export function decrementDose( * in-use deadline (the EXPIRED transition is still correct via * `computeInventoryState`, but the day-count chip shows the * 30-day-clock number). + * + * v1.4.25 W21 Fix-N — widened to accept either the full + * `InventoryItemView` (server-side path: state-machine-gated, returns + * null for non-IN_USE) OR a thin `{ firstUseAt }` shape (client-side + * path: caller already knows state is IN_USE so the gate is a no-op). + * The widening collapses the reimplementation the inventory-section + * client previously carried. */ export function daysRemainingInUse( - item: InventoryItemView, + item: InventoryItemView | { firstUseAt: Date | string | null }, nowMs: number, inUseWindowDays: number = DEFAULT_IN_USE_WINDOW_DAYS, ): number | null { - if (!item.firstUseAt) return null; - const state = computeInventoryState(item, nowMs, inUseWindowDays); - if (state !== "IN_USE") return null; - const deadlineMs = - item.firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; + const firstUseRaw = item.firstUseAt; + if (!firstUseRaw) return null; + const firstUseAt = + firstUseRaw instanceof Date ? firstUseRaw : new Date(firstUseRaw); + // Only the full view triggers the state-machine gate; thin callers + // (the inventory disclosure UI on the medication card) supply just + // `firstUseAt` and have already filtered on state === "IN_USE" before + // reaching here. The gate is silently skipped for the thin form. + if ("state" in item && "dosesRemaining" in item) { + const state = computeInventoryState( + { ...item, firstUseAt }, + nowMs, + inUseWindowDays, + ); + if (state !== "IN_USE") return null; + } + const deadlineMs = firstUseAt.getTime() + inUseWindowDays * MS_PER_DAY; const remainingMs = deadlineMs - nowMs; if (remainingMs <= 0) return 0; return Math.floor(remainingMs / MS_PER_DAY);