Files
HealthLog/src/components/medications/side-effects-section.tsx
T

636 lines
23 KiB
TypeScript

"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import type {
MedicationSideEffectCategory,
MedicationSideEffectEntry,
} from "@/generated/prisma/client";
import {
SIDE_EFFECT_CATEGORY_ORDER,
SIDE_EFFECT_SEVERITY_LADDER,
categoryForEntry,
entriesByCategory,
severityLikertLabel,
} from "@/lib/medications/side-effects/taxonomy";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { apiDelete, apiFetchEnvelope, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
* page. Sits below the drug-level chart and above the intake history
* timeline. Mounted only for `treatmentClass === "GLP1"` rows.
*
* UX shape:
* - Section header + "Log side effect" CTA opens a modal dialog.
* - Inside the dialog: category picker → entry chip-picker (filtered
* by category) → 5-Likert severity ladder → optional notes.
* - Below the header: timeline of the last 30 days, newest first,
* 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 sibling 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.
*/
interface SideEffectRow {
id: string;
category: MedicationSideEffectCategory;
entry: MedicationSideEffectEntry;
severity: number;
occurredAt: string;
notes: string | null;
createdAt: string;
}
interface SideEffectsSectionProps {
medicationId: string;
}
const NOTES_MAX = 280;
function categoryI18nKey(category: MedicationSideEffectCategory): string {
// GI → gi, METABOLIC → metabolic, INJECTION_SITE → injectionSite,
// COGNITIVE → cognitive, GLP1_SPECIFIC → glp1Specific.
switch (category) {
case "GI":
return "gi";
case "METABOLIC":
return "metabolic";
case "INJECTION_SITE":
return "injectionSite";
case "COGNITIVE":
return "cognitive";
case "GLP1_SPECIFIC":
return "glp1Specific";
}
}
function entryI18nKey(entry: MedicationSideEffectEntry): string {
// SCREAMING_SNAKE → camelCase.
return entry
.toLowerCase()
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Entries per page of the side-effect list. */
const SIDE_EFFECTS_PAGE_SIZE = 50;
interface SideEffectPageParam {
/** The window start, fixed when the first page loaded. */
from: string;
/** Exclusive upper bound: just after the oldest entry already shown. */
to: string;
}
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [category, setCategory] = useState<MedicationSideEffectCategory>("GI");
const [entry, setEntry] = useState<MedicationSideEffectEntry | null>(null);
const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null);
const [notes, setNotes] = useState<string>("");
const [formError, setFormError] = useState<string | null>(null);
// v1.4.27 R4 RC2 — sticky-pin the Save / Cancel row by portalling it
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
// its `<form>` association via the HTML `form` attribute.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const formId = useId();
const listKey = queryKeys.medicationSideEffects(medicationId);
// The last 30 days, newest first, one page at a time. The window is fixed
// when the first page loads; each further page asks for the entries before
// the oldest one shown (`to` is exclusive, so the boundary instant is
// re-asked one millisecond later and the overlap is dropped by id).
const {
data,
isLoading,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: listKey,
initialPageParam: null as SideEffectPageParam | null,
queryFn: async ({ pageParam }) => {
const to = pageParam ? new Date(pageParam.to) : new Date();
const from = pageParam
? new Date(pageParam.from)
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`;
const { data: payload } = await apiFetchEnvelope<{
items: SideEffectRow[];
meta: { total: number };
}>(url);
return { ...payload, from: from.toISOString() };
},
getNextPageParam: (lastPage): SideEffectPageParam | undefined => {
if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined;
const oldest = lastPage.items[lastPage.items.length - 1];
return {
from: lastPage.from,
to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(),
};
},
staleTime: 30 * 1000,
});
const createMutation = useMutation({
mutationFn: async (payload: {
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).
return apiPost<SideEffectRow>(
`/api/medications/${medicationId}/side-effects`,
payload,
);
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
});
const deleteMutation = useMutation({
mutationFn: async (logId: string) => {
await apiDelete(`/api/medications/${medicationId}/side-effects/${logId}`);
return true;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: listKey });
},
// v1.16.4 — a failed delete used to fail silently, leaving the row
// in place with no signal that the request was rejected.
onError: (failure) => {
showErrorToast(t("medications.sideEffects.deleteError"), {
cause: failure,
});
},
});
const items = useMemo(() => {
const seen = new Set<string>();
const out: SideEffectRow[] = [];
for (const page of data?.pages ?? []) {
for (const row of page.items) {
if (seen.has(row.id)) continue;
seen.add(row.id);
out.push(row);
}
}
return out;
}, [data]);
const filteredEntries = useMemo(
() => entriesByCategory(category),
[category],
);
const { getRadioProps: getEntryRadioProps } = useRovingRadioGroup({
count: filteredEntries.length,
selectedIndex: entry ? filteredEntries.indexOf(entry) : -1,
onSelect: (index) => setEntry(filteredEntries[index]!),
});
const { getRadioProps: getSeverityRadioProps } = useRovingRadioGroup({
count: SIDE_EFFECT_SEVERITY_LADDER.length,
selectedIndex: severity !== null ? severity - 1 : -1,
onSelect: (index) => setSeverity((index + 1) as 1 | 2 | 3 | 4 | 5),
});
function resetForm() {
setCategory("GI");
setEntry(null);
setSeverity(null);
setNotes("");
setFormError(null);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setFormError(null);
if (!entry) {
setFormError(t("medications.sideEffects.errorEntry"));
return;
}
if (!severity) {
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(
{
entry,
severity,
notes: notes.trim() ? notes.trim().slice(0, NOTES_MAX) : null,
},
{
onSuccess: () => {
resetForm();
setOpen(false);
},
onError: (err) => {
setFormError(err instanceof Error ? err.message : String(err));
},
},
);
}
const headerExtras = (
<>
{items.length > 0 && (
<span className="text-muted-foreground mr-2 font-normal">
({items.length})
</span>
)}
{/* v1.36.x — noting a side effect is an admitted delegated write.
Removing one that is already noted is not. */}
{canAddSideEffect && (
<Button
size="sm"
variant="outline"
onClick={() => {
resetForm();
setOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
{t("medications.sideEffects.addCta")}
</Button>
)}
</>
);
return (
<MedicationDetailSection
titleId="side-effects-heading"
title={t("medications.sideEffects.section")}
headerExtras={headerExtras}
>
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
<span>{t("medications.sideEffects.loading")}</span>
</div>
)}
{!isLoading && isError && (
<QueryErrorCard onRetry={() => void refetch()} />
)}
{!isLoading && !isError && items.length === 0 && (
<p className="text-muted-foreground py-1">
{t("medications.sideEffects.emptyState")}
</p>
)}
{!isLoading && !isError && items.length > 0 && (
<div className="space-y-2">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{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">
{/* v1.4.28 FB-F2 — fixed-width date slot pinned to
the right of the row meta so the date stamp keeps
its column across rows. The category badge +
entry label + severity chip share the left-most
slot; the date claims a 4ch-wide right column. */}
<div className="flex items-start gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<Badge variant="outline" className="text-xs">
{t(
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
)}
</Badge>
<span className="text-foreground truncate text-sm font-medium">
{t(
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
)}
</span>
<Badge variant="secondary" className="text-xs">
{t(
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
)}
</Badge>
</div>
<p
// D-H7 — narrowed from `w-[5.5rem]` (88 px) to
// `w-14` (56 px). The longest short-date variant
// ("15. Mai") fits inside 56 px with slack;
// recovering 32 px relieves the left slot's
// wrap rhythm at 320 px so the category badge +
// entry label + severity chip stop pushing the
// free-text notes off-screen.
className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums"
data-slot="side-effect-row-date"
>
{fmt.dateShortSmart(new Date(row.occurredAt))}
</p>
</div>
{row.notes && (
<p
className="text-foreground/80 line-clamp-2 text-sm break-words"
data-slot="side-effect-row-notes"
>
{row.notes}
</p>
)}
</div>
{canManageMedications && (
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</li>
))}
</ul>
{hasNextPage ? (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
disabled={isFetchingNextPage}
onClick={() => void fetchNextPage()}
>
{isFetchingNextPage
? t("common.loading")
: t("medications.sideEffects.loadMore")}
</Button>
</div>
) : null}
</div>
)}
{/* Themed confirm — replaces the native window.confirm so the delete
gate matches every other destructive action in the app. */}
<AlertDialog
open={!!pendingDeleteId}
onOpenChange={(o) => {
if (!o) setPendingDeleteId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.sideEffects.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.sideEffects.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId);
setPendingDeleteId(null);
}}
disabled={deleteMutation.isPending}
aria-busy={deleteMutation.isPending || undefined}
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.sideEffects.deleteCta")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}
title={t("medications.sideEffects.sheetTitle")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<form
id={formId}
onSubmit={handleSubmit}
className="space-y-4"
aria-label={t("medications.sideEffects.sheetTitle")}
>
<div className="space-y-1.5">
<Label htmlFor="side-effect-category">
{t("medications.sideEffects.categoryLabel")}
</Label>
<Select
value={category}
onValueChange={(next) => {
setCategory(next as MedicationSideEffectCategory);
// Reset entry — the previously-selected entry probably
// doesn't belong to the new category.
setEntry(null);
}}
>
<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">
<Label>{t("medications.sideEffects.entryLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.entryLabel")}
>
{filteredEntries.map((candidate, index) => {
const selected = candidate === entry;
return (
<button
type="button"
key={candidate}
role="radio"
aria-checked={selected}
onClick={() => setEntry(candidate)}
{...getEntryRadioProps(index)}
className={`flex min-h-11 items-center justify-center rounded-full border px-2.5 py-1 text-xs transition-colors sm:min-h-9 ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
{t(
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
)}
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label>{t("medications.sideEffects.severityLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("medications.sideEffects.severityLabel")}
>
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
const selected = severity === value;
return (
<button
type="button"
key={label}
role="radio"
aria-checked={selected}
onClick={() => setSeverity(value)}
{...getSeverityRadioProps(idx)}
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
}`}
>
<span aria-hidden className="block text-sm">
{value}
</span>
<span className="block text-xs opacity-80">
{t(`medications.sideEffects.severity.${label}`)}
</span>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="side-effect-notes">
{t("medications.sideEffects.notesLabel")}
</Label>
<Textarea
id="side-effect-notes"
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
placeholder={t("medications.sideEffects.notesPlaceholder")}
rows={3}
maxLength={NOTES_MAX}
enterKeyHint="done"
// Compact notes-row padding override; primitive's
// px-3 py-2 is too tall inside the side-effect modal.
className="px-2 py-1.5"
/>
<p className="text-muted-foreground text-right text-xs">
{notes.length} / {NOTES_MAX}
</p>
</div>
{formError && (
<p className="text-destructive text-sm" role="alert">
{formError}
</p>
)}
</form>
{footerEl
? createPortal(
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
>
{t("medications.sideEffects.cancelCta")}
</Button>
<Button
type="submit"
form={formId}
disabled={createMutation.isPending}
>
{createMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("medications.sideEffects.submitCta")}
</Button>
</div>,
footerEl,
)
: null}
</ResponsiveSheet>
</MedicationDetailSection>
);
}