refactor(medications): extract MedicationDetailSection wrapper across 4 components

Lock the shared chrome (border + header row + dotted divider + body
band) into one wrapper so the three Wave-4b panels (Titration,
Scheduling, SideEffects) stop drifting on padding, border opacity,
and aria-wiring rules.

- src/components/medications/medication-detail-section.tsx (new) —
  composes the section shell with a stable titleId / headerExtras
  contract.
- TitrationSection — drops the hand-rolled templateFill helper (the
  i18n t() function already accepts the params bag); adopts the
  wrapper.
- SchedulingSection — adopts the wrapper; grows cadence-timeline
  cells to a 44 px tap-target per WCAG 2.5.5 while keeping the 12 px
  visible cell.
- SideEffectsSection — adopts the wrapper; swaps the native <select>
  for the shadcn-radix Select so the picker matches the rest of the
  form library.
- inventory-section — drops the local InventoryState string-union in
  favour of the Prisma MedicationInventoryState enum; replaces the
  reimplemented daysRemainingInUse with the widened pure helper.
- DrugLevelChart — adopts shared ResearchModeStatus,
  findDrugIdByBrand, and parseDoseMg (the local parseDoseMg is
  re-exported for test backwards-compat).
- inventory state-machine — widens daysRemainingInUse to accept
  either the full InventoryItemView or a thin { firstUseAt } shape.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:02:28 +02:00
1 parent c8ee4cb9fc
commit d6456a30eb
8 files changed
+604 -483

No files matched your search

+14 -32
View File
@@ -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<Glp1DrugRecord | null>(
() => 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<ResearchModeStatus | null>({
@@ -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
+169 -164
View File
@@ -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 `<SideEffectsSection>` (W19d) and `<IntakeHistoryList>`
* 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 `<MedicationDetailSection>` 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 = (
<div className="flex items-center gap-2">
<Badge
variant={reminderEnabled ? "secondary" : "outline"}
className="gap-1 text-[10px]"
>
{reminderEnabled ? (
<BellRing className="h-3 w-3" />
) : (
<BellOff className="h-3 w-3" />
)}
{t(
reminderEnabled
? "medications.scheduling.reminders.on"
: "medications.scheduling.reminders.off",
)}
</Badge>
<Button
size="sm"
variant="outline"
asChild
aria-label={t("medications.scheduling.editCta")}
>
<Link href="/medications">
<Pencil className="mr-1.5 h-3.5 w-3.5" />
{t("medications.scheduling.editCta")}
</Link>
</Button>
</div>
);
return (
<section
className="border-border/60 rounded-md border"
aria-labelledby="scheduling-heading"
<MedicationDetailSection
titleId="scheduling-heading"
title={t("medications.scheduling.section")}
headerExtras={headerExtras}
>
<header className="flex items-center justify-between px-3 py-2.5">
<h2
id="scheduling-heading"
className="text-foreground/85 text-sm font-medium"
>
{t("medications.scheduling.section")}
</h2>
<div className="flex items-center gap-2">
<Badge
variant={reminderEnabled ? "secondary" : "outline"}
className="gap-1 text-[10px]"
>
{reminderEnabled ? (
<BellRing className="h-3 w-3" />
) : (
<BellOff className="h-3 w-3" />
)}
{t(
reminderEnabled
? "medications.scheduling.reminders.on"
: "medications.scheduling.reminders.off",
)}
</Badge>
<Button
size="sm"
variant="outline"
asChild
aria-label={t("medications.scheduling.editCta")}
>
<Link href="/medications">
<Pencil className="mr-1.5 h-3.5 w-3.5" />
{t("medications.scheduling.editCta")}
</Link>
</Button>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.scheduling.loading")}</span>
</div>
</header>
)}
<div className="border-border/60 border-t px-3 py-3 text-xs">
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.scheduling.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.scheduling.loadFailed")}
</p>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.scheduling.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-4">
{/* Next-dose chip */}
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground">
{data.next ? (
<>
{t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "}
<span className="text-foreground font-medium">
{fmt.dateShort(new Date(data.next.windowStart))}
</span>
</>
) : (
t("medications.scheduling.cadenceViz.noNextDose")
)}
</p>
</div>
{/* 30-day timeline */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.cadenceViz.title")}
</p>
{data.timeline.length === 0 ? (
<p className="text-muted-foreground py-1">
{t("medications.scheduling.cadenceViz.emptyState")}
</p>
) : (
{data && !isLoading && (
<div className="space-y-4">
{/* Next-dose chip */}
<div className="flex items-center justify-between gap-2">
<p className="text-muted-foreground">
{data.next ? (
<>
<div
className="flex flex-wrap gap-1"
role="img"
aria-label={t(
"medications.scheduling.cadenceViz.ariaTimeline",
)}
>
{data.timeline.map((slot, i) => (
<span
key={`${slot.windowStart}-${i}`}
className={`h-3 w-3 rounded-sm ${STATUS_STYLES[slot.status]}`}
title={`${fmt.dateShort(new Date(slot.windowStart))} — ${t(
`medications.scheduling.cadenceViz.status.${slot.status}`,
)}`}
/>
))}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-[10px]">
{(["taken", "skipped", "missed", "upcoming"] as const).map(
(s) => (
<span key={s} className="inline-flex items-center gap-1">
<span
className={`h-2.5 w-2.5 rounded-sm ${STATUS_STYLES[s]}`}
/>
<span>
{t(
`medications.scheduling.cadenceViz.status.${s}`,
)}
</span>
</span>
),
)}
</div>
{t("medications.scheduling.cadenceViz.nextDoseLabel")}{" "}
<span className="text-foreground font-medium">
{fmt.dateShort(new Date(data.next.windowStart))}
</span>
</>
) : (
t("medications.scheduling.cadenceViz.noNextDose")
)}
</div>
</p>
</div>
{/* Compliance chips */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.compliance.title")}
{/* 30-day timeline */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.cadenceViz.title")}
</p>
{data.timeline.length === 0 ? (
<p className="text-muted-foreground py-1">
{t("medications.scheduling.cadenceViz.emptyState")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<ComplianceChip
label={t(
"medications.scheduling.compliance.adherenceRate",
) : (
<>
<div
className="flex flex-wrap gap-1"
role="img"
aria-label={t(
"medications.scheduling.cadenceViz.ariaTimeline",
)}
value={adherenceLabel ?? "—"}
tooltip={t(
"medications.scheduling.compliance.chips.adherenceTooltip",
data-slot="cadence-timeline"
>
{data.timeline.map((slot, i) => {
const slotLabel = `${fmt.dateShort(new Date(slot.windowStart))} — ${t(
`medications.scheduling.cadenceViz.status.${slot.status}`,
)}`;
return (
<button
type="button"
key={`${slot.windowStart}-${i}`}
title={slotLabel}
aria-label={slotLabel}
data-slot="cadence-timeline-cell"
data-status={slot.status}
// 44×44 tap target per WCAG 2.5.5; the visible
// cell stays the 12 px square the design system
// pins inside, centered with flex.
className="inline-flex h-11 w-11 items-center justify-center rounded-sm focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2"
>
<span
className={`h-3 w-3 rounded-sm ${STATUS_STYLES[slot.status]}`}
aria-hidden="true"
/>
</button>
);
})}
</div>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 pt-1 text-[10px]">
{(["taken", "skipped", "missed", "upcoming"] as const).map(
(s) => (
<span key={s} className="inline-flex items-center gap-1">
<span
className={`h-2.5 w-2.5 rounded-sm ${STATUS_STYLES[s]}`}
/>
<span>
{t(
`medications.scheduling.cadenceViz.status.${s}`,
)}
</span>
</span>
),
)}
/>
<ComplianceChip
label={t(
"medications.scheduling.compliance.currentStreak",
)}
value={`${data.chips.currentStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.streakTooltip",
)}
/>
<ComplianceChip
label={t(
"medications.scheduling.compliance.longestStreak",
)}
value={`${data.chips.longestStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.longestTooltip",
)}
/>
<ComplianceChip
label={t(
"medications.scheduling.compliance.missedLast30",
)}
value={`${data.chips.missedLast30} ${t(
"medications.scheduling.compliance.unit.doses",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.missedTooltip",
)}
/>
</div>
</div>
</>
)}
</div>
{/* Compliance chips */}
<div className="space-y-1.5">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.scheduling.compliance.title")}
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<ComplianceChip
label={t("medications.scheduling.compliance.adherenceRate")}
value={adherenceLabel ?? "—"}
tooltip={t(
"medications.scheduling.compliance.chips.adherenceTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.currentStreak")}
value={`${data.chips.currentStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.streakTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.longestStreak")}
value={`${data.chips.longestStreak} ${t(
"medications.scheduling.compliance.unit.days",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.longestTooltip",
)}
/>
<ComplianceChip
label={t("medications.scheduling.compliance.missedLast30")}
value={`${data.chips.missedLast30} ${t(
"medications.scheduling.compliance.unit.doses",
)}`}
tooltip={t(
"medications.scheduling.compliance.chips.missedTooltip",
)}
/>
</div>
</div>
)}
</div>
</section>
</div>
)}
</MedicationDetailSection>
);
}
+139 -118
View File
@@ -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 `<MedicationDetailSection>` so
* the three Wave-4b sections share one chrome contract, and swaps the
* native `<select>` for the shadcn-radix `<Select>` 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 && (
<span className="text-muted-foreground mr-2 font-normal">
({items.length})
</span>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
</>
);
return (
<section
className="border-border/60 rounded-md border"
aria-labelledby="side-effects-heading"
<MedicationDetailSection
titleId="side-effects-heading"
title={t("medications.sideEffects.section")}
headerExtras={headerExtras}
bodyPaddingY="py-2.5"
>
<header className="flex items-center justify-between px-3 py-2.5">
<h2
id="side-effects-heading"
className="text-foreground/85 text-sm font-medium"
>
{t("medications.sideEffects.section")}
{items.length > 0 && (
<span className="text-muted-foreground ml-1.5 font-normal">
({items.length})
</span>
)}
</h2>
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
</header>
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.sideEffects.loading")}</span>
</div>
)}
<div className="border-border/60 border-t px-3 py-2.5 text-xs">
{isLoading && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.sideEffects.loading")}</span>
</div>
)}
{!isLoading && items.length === 0 && (
<p className="text-muted-foreground py-1">
{t("medications.sideEffects.emptyState")}
</p>
)}
{!isLoading && items.length === 0 && (
<p className="text-muted-foreground py-1">
{t("medications.sideEffects.emptyState")}
{!isLoading && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.sideEffects.recentTitle")}
</p>
)}
{!isLoading && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.sideEffects.recentTitle")}
</p>
<ul className="space-y-1.5">
{items.map((row) => (
<li
key={row.id}
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
)}
</Badge>
<span className="text-foreground font-medium">
{t(
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
)}
</span>
<Badge variant="secondary" className="text-[10px]">
{t(
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
)}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
{fmt.dateShort(new Date(row.occurredAt))}
</p>
{row.notes && (
<p className="text-foreground/80 whitespace-pre-wrap break-words">
{row.notes}
</p>
)}
<ul className="space-y-1.5">
{items.map((row) => (
<li
key={row.id}
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
{t(
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
)}
</Badge>
<span className="text-foreground font-medium">
{t(
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
)}
</span>
<Badge variant="secondary" className="text-[10px]">
{t(
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
)}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => {
if (
typeof window === "undefined" ||
window.confirm(
t("medications.sideEffects.deleteConfirm"),
)
) {
deleteMutation.mutate(row.id);
}
}}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</div>
)}
</div>
<p className="text-muted-foreground text-[11px]">
{fmt.dateShort(new Date(row.occurredAt))}
</p>
{row.notes && (
<p className="text-foreground/80 whitespace-pre-wrap break-words">
{row.notes}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => {
if (
typeof window === "undefined" ||
window.confirm(
t("medications.sideEffects.deleteConfirm"),
)
) {
deleteMutation.mutate(row.id);
}
}}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</div>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
@@ -330,26 +345,32 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
<Label htmlFor="side-effect-category">
{t("medications.sideEffects.categoryLabel")}
</Label>
<select
id="side-effect-category"
<Select
value={category}
onChange={(e) => {
const next = e.target.value as MedicationSideEffectCategory;
setCategory(next);
onValueChange={(next) => {
setCategory(next as MedicationSideEffectCategory);
// Reset entry — the previously-selected entry probably
// doesn't belong to the new category.
setEntry(null);
}}
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
>
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
<option key={c} value={c}>
{t(
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
)}
</option>
))}
</select>
<SelectTrigger
id="side-effect-category"
className="w-full"
data-slot="side-effect-category-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
<SelectItem key={c} value={c}>
{t(
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
@@ -460,6 +481,6 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</form>
</DialogContent>
</Dialog>
</section>
</MedicationDetailSection>
);
}
+129 -142
View File
@@ -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 `<SchedulingSection>` (W19e) and `<IntakeHistoryList>`
* 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 `<MedicationDetailSection>` 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, string | number>,
): 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 (
<section
className="border-border/60 rounded-md border"
aria-labelledby="titration-heading"
data-slot="titration-section"
const headerExtras = data ? (
<span
className="text-muted-foreground text-[10px] uppercase tracking-wide"
data-slot="titration-drug-inn"
>
<header className="flex items-center justify-between px-3 py-2.5">
<h2
id="titration-heading"
className="text-foreground/85 text-sm font-medium"
>
{t("medications.titration.section")}
</h2>
{data && (
<span
className="text-muted-foreground text-[10px] uppercase tracking-wide"
data-slot="titration-drug-inn"
>
{data.drugInn}
</span>
)}
</header>
{data.drugInn}
</span>
) : null;
<div className="border-border/60 border-t px-3 py-3 text-xs">
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.titration.loading")}</span>
</div>
)}
return (
<MedicationDetailSection
titleId="titration-heading"
title={t("medications.titration.section")}
headerExtras={headerExtras}
dataSlot="titration-section"
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.titration.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.titration.loadFailed")}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.titration.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-3">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.titration.drugLadderHeader")}
</p>
)}
{data && !isLoading && (
<div className="space-y-3">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.titration.drugLadderHeader")}
{hasLadder ? (
<ol
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-stretch"
aria-label={t("medications.titration.drugLadderHeader")}
>
{data.ladder.map((step) => {
const isCurrent = currentIdx === step.stepIndex;
const isPast =
currentIdx !== null && step.stepIndex < currentIdx;
return (
<li
key={step.stepIndex}
className={[
"border-border/70 flex flex-1 flex-col rounded-md border px-2.5 py-2 sm:min-w-[7rem]",
isCurrent
? "border-primary ring-primary/40 ring-2"
: "",
].join(" ")}
aria-current={isCurrent ? "step" : undefined}
data-slot="titration-step"
data-step-index={step.stepIndex}
data-step-state={
isCurrent ? "current" : isPast ? "past" : "upcoming"
}
>
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{t("medications.titration.stepLabel", {
n: step.stepIndex + 1,
})}
</span>
<span className="text-foreground text-sm font-medium">
{step.doseMg} {t("medications.titration.doseUnitMg")}
</span>
<span className="text-muted-foreground text-[10px]">
{t("medications.titration.typicalWeeksOnStep", {
weeks: step.typicalWeeks,
})}
</span>
{isCurrent && (
<span className="text-primary mt-1 text-[10px] font-medium uppercase tracking-wide">
{t("medications.titration.youAreHere")}
</span>
)}
</li>
);
})}
</ol>
) : (
<p className="text-muted-foreground">
{t("medications.titration.emptyState")}
</p>
)}
{hasLadder ? (
<ol
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-stretch"
aria-label={t("medications.titration.drugLadderHeader")}
>
{data.ladder.map((step) => {
const isCurrent = currentIdx === step.stepIndex;
const isPast =
currentIdx !== null && step.stepIndex < currentIdx;
return (
<li
key={step.stepIndex}
className={[
"border-border/70 flex flex-1 flex-col rounded-md border px-2.5 py-2 sm:min-w-[7rem]",
isCurrent
? "border-primary ring-primary/40 ring-2"
: "",
].join(" ")}
aria-current={isCurrent ? "step" : undefined}
data-slot="titration-step"
data-step-index={step.stepIndex}
data-step-state={
isCurrent ? "current" : isPast ? "past" : "upcoming"
}
>
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{templateFill(t("medications.titration.stepLabel"), {
n: step.stepIndex + 1,
})}
</span>
<span className="text-foreground text-sm font-medium">
{step.doseMg} {t("medications.titration.doseUnitMg")}
</span>
<span className="text-muted-foreground text-[10px]">
{templateFill(
t("medications.titration.typicalWeeksOnStep"),
{ weeks: step.typicalWeeks },
)}
</span>
{isCurrent && (
<span className="text-primary mt-1 text-[10px] font-medium uppercase tracking-wide">
{t("medications.titration.youAreHere")}
</span>
)}
</li>
);
})}
</ol>
) : (
<p className="text-muted-foreground">
{t("medications.titration.emptyState")}
</p>
)}
{data.currentStep === null && hasLadder && (
<p className="text-muted-foreground">
{t("medications.titration.nonStandardDose")}
</p>
)}
{data.currentStep === null && hasLadder && (
<p className="text-muted-foreground">
{t("medications.titration.nonStandardDose")}
</p>
)}
{dwellHint && (
<p
className="text-muted-foreground"
data-slot="titration-dwell-hint"
>
{dwellHint}
</p>
)}
{dwellHint && (
<p
className="text-muted-foreground"
data-slot="titration-dwell-hint"
>
{dwellHint}
</p>
)}
{nextStepCaption && (
<p className="text-muted-foreground">{nextStepCaption}</p>
)}
{nextStepCaption && (
<p className="text-muted-foreground">{nextStepCaption}</p>
)}
{data.nextStep === null && data.currentStep !== null && (
<p className="text-muted-foreground">
{t("medications.titration.ceilingMessage")}
</p>
)}
{data.nextStep === null && data.currentStep !== null && (
<p className="text-muted-foreground">
{t("medications.titration.ceilingMessage")}
</p>
)}
<div className="border-border/60 border-t pt-2">
<p className="text-muted-foreground text-[10px]">
{t("medications.titration.disclaimer")}
</p>
<a
href={data.sourceEMA}
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-[10px] underline-offset-2 hover:underline"
>
{t("medications.titration.emaSourceCta")}
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
</a>
</div>
<div className="border-border/60 border-t pt-2">
<p className="text-muted-foreground text-[10px]">
{t("medications.titration.disclaimer")}
</p>
<a
href={data.sourceEMA}
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-[10px] underline-offset-2 hover:underline"
>
{t("medications.titration.emaSourceCta")}
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
</a>
</div>
)}
</div>
</section>
</div>
)}
</MedicationDetailSection>
);
}
@@ -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";
@@ -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 `<slot>-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 (
<section
className="border-border/60 rounded-md border"
aria-labelledby={titleId}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<header className="flex items-center justify-between gap-2 px-3 py-2.5">
<h2
id={titleId}
className="text-foreground/85 text-sm font-medium"
>
{title}
</h2>
{headerExtras}
</header>
<div
className={`border-border/60 border-t px-3 text-xs ${bodyPaddingY}`}
>
{children}
</div>
</section>
);
}
@@ -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);
});
});
});
+25 -6
View File
@@ -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);