mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
c8ee4cb9fc
commit
d6456a30eb
8 files changed
+604
-483
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user