From 8c81af10d6b302843f5146b4b96120c2bcb49a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 01:27:40 +0200 Subject: [PATCH] chore(medications): drop the Dosis-Historie disclosure from GLP-1 detail Retire the inline dose-history `
` block on the GLP-1 medication card and the IntakeHistoryList mount on the standalone medication detail page. The IntakeHistoryList component had no other consumer, so the file retires together with its orphan i18n keys. The iOS-consumed GET /api/medications/[id]/intake endpoint, its Zod schema and the doseChanges wire field on /api/medications/[id]/glp1 all stay byte-stable. --- messages/de.json | 18 +- messages/en.json | 18 +- messages/es.json | 18 +- messages/fr.json | 18 +- messages/it.json | 18 +- messages/pl.json | 18 +- src/app/medications/[id]/history/page.tsx | 40 +- .../medications/SchedulingSection.tsx | 7 +- .../medications/TitrationSection.tsx | 9 +- .../__tests__/medication-card-glp1.test.tsx | 25 +- .../medications/glp1-medication-card.tsx | 64 +- .../medications/intake-history-list.tsx | 886 ------------------ 12 files changed, 42 insertions(+), 1097 deletions(-) delete mode 100644 src/components/medications/intake-history-list.tsx diff --git a/messages/de.json b/messages/de.json index 968538471..b9b7f4634 100644 --- a/messages/de.json +++ b/messages/de.json @@ -443,20 +443,7 @@ "daysThu": "Do", "daysFri": "Fr", "daysSat": "Sa", - "intakeHistoryTitle": "Einnahme-Verlauf", "back": "Zurück", - "newIntake": "Hinzufügen", - "editIntake": "Einnahme bearbeiten", - "intakeScheduledFor": "Geplant", - "intakeTakenAt": "Eingenommen um", - "intakeStatus": "Status", - "intakeSource": "Quelle", - "intakeStatusTaken": "Eingenommen", - "intakeStatusSkipped": "Übersprungen", - "intakeStatusMissed": "Verpasst", - "intakeDeleteConfirm": "Einnahme-Eintrag löschen?", - "intakeDeleteDescription": "Dieser Einnahme-Eintrag wird unwiderruflich gelöscht.", - "intakeCount": "{count} Einnahmen", "today": "Heute", "yesterday": "Gestern", "tomorrow": "Morgen", @@ -491,7 +478,7 @@ "formUnit": "Einheit", "formSchedule": "Zeitfenster", "formTreatmentClass": "Behandlungsklasse", - "formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte, die Dosis-Historie und den Pen-Bestand unten frei.", + "formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte und das GLP-1-Logbuch frei.", "treatmentClassGeneric": "Standard-Medikament", "treatmentClassGlp1": "GLP-1-Injektion", "glp1WeeklyPresetTitle": "Einmal wöchentlich am …", @@ -505,9 +492,6 @@ "glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)", "glp1NextInjectionToday": "Nächster Termin: heute", "glp1NextInjectionTomorrow": "Nächster Termin: morgen", - "glp1DoseHistory": "Dosis-Historie", - "glp1DoseHistoryEmpty": "Noch keine Dosis-Änderung erfasst.", - "glp1DoseSince": "seit {date}", "glp1RotationLast": "Letzte Stelle: {site}", "glp1RotationSuggested": "Empfohlen: {site}", "glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite", diff --git a/messages/en.json b/messages/en.json index d877884e6..449c66f8b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -443,20 +443,7 @@ "daysThu": "Thu", "daysFri": "Fri", "daysSat": "Sat", - "intakeHistoryTitle": "Intake History", "back": "Back", - "newIntake": "Add", - "editIntake": "Edit Intake", - "intakeScheduledFor": "Scheduled", - "intakeTakenAt": "Taken at", - "intakeStatus": "Status", - "intakeSource": "Source", - "intakeStatusTaken": "Taken", - "intakeStatusSkipped": "Skipped", - "intakeStatusMissed": "Missed", - "intakeDeleteConfirm": "Delete intake record?", - "intakeDeleteDescription": "This intake record will be permanently deleted.", - "intakeCount": "{count} intakes", "today": "Today", "yesterday": "Yesterday", "tomorrow": "Tomorrow", @@ -491,7 +478,7 @@ "formUnit": "Unit", "formSchedule": "Schedule", "formTreatmentClass": "Treatment class", - "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.", + "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.", "treatmentClassGeneric": "Standard medication", "treatmentClassGlp1": "GLP-1 injection", "glp1WeeklyPresetTitle": "Once weekly on …", @@ -505,9 +492,6 @@ "glp1NextInjectionDays": "Next: {label} (in {days} days)", "glp1NextInjectionToday": "Next: today", "glp1NextInjectionTomorrow": "Next: tomorrow", - "glp1DoseHistory": "Dose history", - "glp1DoseHistoryEmpty": "No dose changes logged yet.", - "glp1DoseSince": "since {date}", "glp1RotationLast": "Last site: {site}", "glp1RotationSuggested": "Recommended next: {site}", "glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply", diff --git a/messages/es.json b/messages/es.json index ac7d4c25a..c1cf596a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -443,20 +443,7 @@ "daysThu": "Thu", "daysFri": "Fri", "daysSat": "Sat", - "intakeHistoryTitle": "Intake History", "back": "Atrás", - "newIntake": "Añadir", - "editIntake": "Edit Intake", - "intakeScheduledFor": "Scheduled", - "intakeTakenAt": "Taken at", - "intakeStatus": "Estado", - "intakeSource": "Source", - "intakeStatusTaken": "Tomada", - "intakeStatusSkipped": "Omitida", - "intakeStatusMissed": "Perdida", - "intakeDeleteConfirm": "Delete intake record?", - "intakeDeleteDescription": "This intake record will be permanently deleted.", - "intakeCount": "{count} intakes", "today": "Hoy", "yesterday": "Ayer", "tomorrow": "Mañana", @@ -491,7 +478,7 @@ "formUnit": "Unit", "formSchedule": "Schedule", "formTreatmentClass": "Treatment class", - "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.", + "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.", "treatmentClassGeneric": "Standard medication", "treatmentClassGlp1": "GLP-1 injection", "glp1WeeklyPresetTitle": "Once weekly on …", @@ -505,9 +492,6 @@ "glp1NextInjectionDays": "Siguiente:{label} (in {days} days)", "glp1NextInjectionToday": "Next: today", "glp1NextInjectionTomorrow": "Next: tomorrow", - "glp1DoseHistory": "Dose history", - "glp1DoseHistoryEmpty": "No dose changes logged yet.", - "glp1DoseSince": "since {date}", "glp1RotationLast": "Last site: {site}", "glp1RotationSuggested": "Recommended next: {site}", "glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply", diff --git a/messages/fr.json b/messages/fr.json index 65b92f416..4069e9223 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -443,20 +443,7 @@ "daysThu": "Thu", "daysFri": "Fri", "daysSat": "Sat", - "intakeHistoryTitle": "Intake History", "back": "Retour", - "newIntake": "Ajouter", - "editIntake": "Edit Intake", - "intakeScheduledFor": "Scheduled", - "intakeTakenAt": "Taken at", - "intakeStatus": "Statut", - "intakeSource": "Source", - "intakeStatusTaken": "Pris", - "intakeStatusSkipped": "Sauté", - "intakeStatusMissed": "Manqué", - "intakeDeleteConfirm": "Delete intake record?", - "intakeDeleteDescription": "This intake record will be permanently deleted.", - "intakeCount": "{count} intakes", "today": "Aujourd'hui", "yesterday": "Hier", "tomorrow": "Demain", @@ -491,7 +478,7 @@ "formUnit": "Unit", "formSchedule": "Schedule", "formTreatmentClass": "Treatment class", - "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.", + "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.", "treatmentClassGeneric": "Standard medication", "treatmentClassGlp1": "GLP-1 injection", "glp1WeeklyPresetTitle": "Once weekly on …", @@ -505,9 +492,6 @@ "glp1NextInjectionDays": "Suivant:{label} (in {days} days)", "glp1NextInjectionToday": "Next: today", "glp1NextInjectionTomorrow": "Next: tomorrow", - "glp1DoseHistory": "Dose history", - "glp1DoseHistoryEmpty": "No dose changes logged yet.", - "glp1DoseSince": "since {date}", "glp1RotationLast": "Last site: {site}", "glp1RotationSuggested": "Recommended next: {site}", "glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply", diff --git a/messages/it.json b/messages/it.json index 7acbd1cea..6bd5e8b8b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -443,20 +443,7 @@ "daysThu": "Thu", "daysFri": "Fri", "daysSat": "Sat", - "intakeHistoryTitle": "Intake History", "back": "Indietro", - "newIntake": "Aggiungi", - "editIntake": "Edit Intake", - "intakeScheduledFor": "Scheduled", - "intakeTakenAt": "Taken at", - "intakeStatus": "Stato", - "intakeSource": "Source", - "intakeStatusTaken": "Assunta", - "intakeStatusSkipped": "Saltata", - "intakeStatusMissed": "Persa", - "intakeDeleteConfirm": "Delete intake record?", - "intakeDeleteDescription": "This intake record will be permanently deleted.", - "intakeCount": "{count} intakes", "today": "Oggi", "yesterday": "Ieri", "tomorrow": "Domani", @@ -491,7 +478,7 @@ "formUnit": "Unit", "formSchedule": "Schedule", "formTreatmentClass": "Treatment class", - "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.", + "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.", "treatmentClassGeneric": "Standard medication", "treatmentClassGlp1": "GLP-1 injection", "glp1WeeklyPresetTitle": "Once weekly on …", @@ -505,9 +492,6 @@ "glp1NextInjectionDays": "Avanti:{label} (in {days} days)", "glp1NextInjectionToday": "Next: today", "glp1NextInjectionTomorrow": "Next: tomorrow", - "glp1DoseHistory": "Dose history", - "glp1DoseHistoryEmpty": "No dose changes logged yet.", - "glp1DoseSince": "since {date}", "glp1RotationLast": "Last site: {site}", "glp1RotationSuggested": "Recommended next: {site}", "glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply", diff --git a/messages/pl.json b/messages/pl.json index 0be73af6f..138100ce1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -443,20 +443,7 @@ "daysThu": "Thu", "daysFri": "Fri", "daysSat": "Sat", - "intakeHistoryTitle": "Intake History", "back": "Wstecz", - "newIntake": "Dodaj", - "editIntake": "Edit Intake", - "intakeScheduledFor": "Scheduled", - "intakeTakenAt": "Taken at", - "intakeStatus": "Status", - "intakeSource": "Source", - "intakeStatusTaken": "Przyjęte", - "intakeStatusSkipped": "Pominięte", - "intakeStatusMissed": "Pominięte", - "intakeDeleteConfirm": "Delete intake record?", - "intakeDeleteDescription": "This intake record will be permanently deleted.", - "intakeCount": "{count} intakes", "today": "Dzisiaj", "yesterday": "Wczoraj", "tomorrow": "Jutro", @@ -491,7 +478,7 @@ "formUnit": "Unit", "formSchedule": "Schedule", "formTreatmentClass": "Treatment class", - "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.", + "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.", "treatmentClassGeneric": "Standard medication", "treatmentClassGlp1": "GLP-1 injection", "glp1WeeklyPresetTitle": "Once weekly on …", @@ -505,9 +492,6 @@ "glp1NextInjectionDays": "Dalej:{label} (in {days} days)", "glp1NextInjectionToday": "Next: today", "glp1NextInjectionTomorrow": "Next: tomorrow", - "glp1DoseHistory": "Dose history", - "glp1DoseHistoryEmpty": "No dose changes logged yet.", - "glp1DoseSince": "since {date}", "glp1RotationLast": "Last site: {site}", "glp1RotationSuggested": "Recommended next: {site}", "glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply", diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 1c495ef65..527a7f1bb 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -1,16 +1,15 @@ "use client"; -import { useEffect, use, useState } from "react"; +import { useEffect, use } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { IntakeHistoryList } from "@/components/medications/intake-history-list"; import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; import { SchedulingSection } from "@/components/medications/SchedulingSection"; import { TitrationSection } from "@/components/medications/TitrationSection"; -import { ArrowLeft, Loader2, Plus } from "lucide-react"; +import { ArrowLeft, Loader2 } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "@/lib/i18n/context"; @@ -23,7 +22,6 @@ export default function IntakeHistoryPage({ const { isAuthenticated, isLoading: authLoading } = useAuth(); const router = useRouter(); const { t } = useTranslations(); - const [createOpen, setCreateOpen] = useState(false); useEffect(() => { if (!authLoading && !isAuthenticated) { @@ -70,22 +68,18 @@ export default function IntakeHistoryPage({ -
-
-

- {t("medications.intakeHistoryTitle")} -

- {medication && ( + {medication && ( +
+
+

+ {medication.name} +

- {medication.name} — {medication.dose} + {medication.dose}

- )} +
- -
+ )} {/* v1.4.25 W19c-Frontend — Research-mode-gated drug-level chart. Renders only for GLP-1 medications; the component itself @@ -127,18 +121,12 @@ export default function IntakeHistoryPage({ {/* v1.4.25 W19f — GLP-1 titration-ladder display. Read-only EMA reference visual showing the standard dose-escalation schedule with the user's current step highlighted. Sits between - SchedulingSection and IntakeHistoryList so the user lands on - cycle context (chart) -> side effects -> cadence/adherence -> - titration position -> per-dose timeline. */} + SchedulingSection and the bottom of the page; v1.4.28 retired + the IntakeHistoryList block that used to anchor the per-dose + timeline below this section. */} {medication?.treatmentClass === "GLP1" && ( )} - -
); } diff --git a/src/components/medications/SchedulingSection.tsx b/src/components/medications/SchedulingSection.tsx index d8548f160..6553e9631 100644 --- a/src/components/medications/SchedulingSection.tsx +++ b/src/components/medications/SchedulingSection.tsx @@ -13,9 +13,10 @@ import { MedicationDetailSection } from "@/components/medications/medication-det /** * v1.4.25 W19e — GLP-1 cadence + compliance section. * - * Sits between `` (W19d) and `` - * on the medication detail page. Same chrome as the W19d / W19f - * sections, composed via the shared `` wrapper. + * Sits between `` (W19d) and `` + * on the medication detail page (v1.4.28 retired the IntakeHistoryList + * block below). Same chrome as the W19d / W19f sections, composed via + * the shared `` wrapper. * * Three sub-sections, top-to-bottom: * diff --git a/src/components/medications/TitrationSection.tsx b/src/components/medications/TitrationSection.tsx index 4567433c3..923a3310d 100644 --- a/src/components/medications/TitrationSection.tsx +++ b/src/components/medications/TitrationSection.tsx @@ -10,10 +10,11 @@ import { MedicationDetailSection } from "@/components/medications/medication-det /** * v1.4.25 W19f — GLP-1 titration-ladder section. * - * Sits between `` (W19e) and `` - * on the medication detail page. Same chrome as the W19d / W19e - * sections so the Wave-4b panels read as one visual group — chrome is - * now composed via the shared `` wrapper. + * Sits at the bottom of the medication detail page below the W19e + * SchedulingSection (v1.4.28 retired the IntakeHistoryList block). + * Same chrome as the W19d / W19e sections so the panels read as one + * visual group — chrome is now composed via the shared + * `` wrapper. * * Layout: * - Header: drug INN + "Standard ladder (EMA reference)" diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index b4b44a487..03903569b 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -245,11 +245,11 @@ describe(" — GLP-1 variant rendering", () => { expect(html).toMatch(/Next:/); }); - it("renders the inline dose-history disclosure (closed by default)", () => { - // The collapsible
element keeps the card height matched - // to a generic card on first paint. SSR includes the element with - // no `open=""` attribute; clicking the summary toggles state via - // React's onToggle handler (not exercisable from SSR). + it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => { + // The dose-history `
` block was retired in v1.4.28 per + // maintainer feedback. The doseChanges payload still arrives from + // the API (iOS contract preserved), but the GLP-1 card no longer + // paints the disclosure or the summary label. const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, { @@ -276,16 +276,8 @@ describe(" — GLP-1 variant rendering", () => { client, ); - // The
wrapper renders with the localised summary. - expect(html).toMatch(/]*>/); - expect(html).toContain("Dose history"); - // Closed by default — no `open=""` attribute on the
. - const detailsTag = html.match(/]*>/)?.[0] ?? ""; - expect(detailsTag).not.toMatch(/\sopen(=""|\s|>)/); - // Dose-history rows are present in the markup even though they're - // visually hidden until expanded. - expect(html).toContain("5 mg"); - expect(html).toContain("7.5 mg"); + expect(html).not.toContain("Dose history"); + expect(html).not.toContain("Dosis-Historie"); }); it("renders the injection-site rotation marker (last + recommended)", () => { @@ -475,6 +467,7 @@ describe(" — GLP-1 variant rendering", () => { expect(html).toContain("GLP-1-Injektion"); expect(html).toContain("Letzter Termin:"); expect(html).toContain("Bauch, unten links"); - expect(html).toContain("Dosis-Historie"); + // v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card. + expect(html).not.toContain("Dosis-Historie"); }); }); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index d81aedefa..7d9b8da5a 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -1,11 +1,10 @@ "use client"; -import { useId, useState } from "react"; +import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { Check, - ChevronDown, Flame, History, Loader2, @@ -34,9 +33,9 @@ import { InventorySection } from "@/components/medications/inventory-section"; * * Marc directive 2026-05-14: NO chart on the medication card. The card * stays text-rich (drug name + current dose, last/next injection, - * inline dose-history disclosure, injection-site rotation hint, pen - * inventory, side-effect quick-log). Chart-only surfaces are the - * Dashboard tile + Insights /medikamente sub-page. + * injection-site rotation hint, pen inventory, side-effect quick-log). + * v1.4.28 retired the inline dose-history disclosure. Chart-only + * surfaces are the Dashboard tile + Insights /medikamente sub-page. * * Renders inside the standard medications grid alongside generic * medication cards; visually mirrors `medication-card.tsx` so the page @@ -160,8 +159,6 @@ export function Glp1MedicationCard({ const { t } = useTranslations(); const fmt = useFormatters(); const [intakeLoading, setIntakeLoading] = useState(null); - const [showHistory, setShowHistory] = useState(false); - const doseHistoryId = useId(); const { data: compliance } = useQuery({ queryKey: ["medications", medication.id, "compliance"], @@ -213,7 +210,6 @@ export function Glp1MedicationCard({ const rate30 = compliance?.compliance30?.rate ?? 0; const streak = compliance?.compliance7?.streak ?? 0; - const doseChanges = details?.doseChanges ?? []; const recentInjections = details?.recentIntakes ?? []; const lastSite = recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null; @@ -359,58 +355,6 @@ export function Glp1MedicationCard({ )} - {/* Dose-history disclosure — collapsible row. Stays closed by - default so the card height matches a generic card on first - paint. */} -
- setShowHistory((e.target as HTMLDetailsElement).open) - } - > - - {t("medications.glp1DoseHistory")} - - -
- {doseChanges.length === 0 && ( -

- {t("medications.glp1DoseHistoryEmpty")} -

- )} - {doseChanges - .slice() - .reverse() - .map((change) => ( -
- - {change.doseValue} {change.doseUnit} - - - {t("medications.glp1DoseSince", { - date: fmt.dateShort(new Date(change.effectiveFrom)), - })} - -
- ))} -
-
- {/* v1.4.25 W19b — per-pen / per-vial inventory disclosure. Lazy-loads the inventory list on open; otherwise stays collapsed so the card height matches generic cards. */} diff --git a/src/components/medications/intake-history-list.tsx b/src/components/medications/intake-history-list.tsx deleted file mode 100644 index 5081e2cb3..000000000 --- a/src/components/medications/intake-history-list.tsx +++ /dev/null @@ -1,886 +0,0 @@ -"use client"; - -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; -import { useAuth } from "@/hooks/use-auth"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { DateTimeInput } from "@/components/ui/date-input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Loader2, - Pencil, - Trash2, - ChevronLeft, - ChevronRight, - ArrowUp, - ArrowDown, - ArrowUpDown, - MoreHorizontal, - Check, - SkipForward, - AlertTriangle, -} from "lucide-react"; -import { useId, useState, useCallback } from "react"; -import { createPortal } from "react-dom"; -import { formatDateTime } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; - -interface IntakeEvent { - id: string; - medicationId: string; - scheduledFor: string; - takenAt: string | null; - skipped: boolean; - source: string; - createdAt: string; -} - -interface IntakeHistoryListProps { - medicationId: string; - createOpen?: boolean; - onCreateOpenChange?: (open: boolean) => void; -} - -// SOURCE_LABELS built dynamically via t() in the component - -const PAGE_SIZE = 25; - -function toDateTimeLocalValue(isoString: string): string { - const date = new Date(isoString); - const offset = date.getTimezoneOffset(); - const local = new Date(date.getTime() - offset * 60 * 1000); - return local.toISOString().slice(0, 16); -} - -function dateTimeLocalToISO(value: string): string { - return new Date(value).toISOString(); -} - -export function IntakeHistoryList({ - medicationId, - createOpen, - onCreateOpenChange, -}: IntakeHistoryListProps) { - const { isAuthenticated } = useAuth(); - const queryClient = useQueryClient(); - const { t } = useTranslations(); - - const sourceLabels: Record = { - WEB: t("medications.sourceWeb"), - API: t("medications.sourceApi"), - REMINDER: t("medications.sourceReminder"), - IMPORT: t("medications.sourceImport"), - }; - - const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("scheduledFor"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); - - // Edit state - const [editing, setEditing] = useState(null); - const [editScheduledFor, setEditScheduledFor] = useState(""); - const [editTakenAt, setEditTakenAt] = useState(""); - const [editSkipped, setEditSkipped] = useState(false); - const [editError, setEditError] = useState(null); - const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); - // v1.4.27 MB3 — error banners reference the form inputs via - // aria-describedby so screen readers announce validation failures. - const editErrorId = useId(); - const editErrorDescriptor = editError ? editErrorId : undefined; - - // v1.4.27 R4 RC2 — portal targets + stable form ids so the edit and - // create sheets sticky-pin Save / Cancel via the - // `` footer slot. Submit buttons keep their `
` - // association via the HTML `form` attribute. - const editFormId = useId(); - const createFormId = useId(); - const [editFooterEl, setEditFooterEl] = useState(null); - const [createFooterEl, setCreateFooterEl] = useState( - null, - ); - - // Create state — controlled via props or internal - const [internalCreating, setInternalCreating] = useState(false); - const creating = createOpen ?? internalCreating; - const [createScheduledFor, setCreateScheduledFor] = useState(""); - const [createTakenAt, setCreateTakenAt] = useState(""); - const [createSkipped, setCreateSkipped] = useState(false); - const [createError, setCreateError] = useState(null); - const createErrorId = useId(); - const createErrorDescriptor = createError ? createErrorId : undefined; - const setCreatingRaw = onCreateOpenChange ?? setInternalCreating; - const setCreating = useCallback( - (open: boolean) => { - if (open) { - const now = toDateTimeLocalValue(new Date().toISOString()); - setCreateScheduledFor(now); - setCreateTakenAt(now); - setCreateSkipped(false); - setCreateError(null); - } - setCreatingRaw(open); - }, - [setCreatingRaw], - ); - - function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "scheduledFor" ? "desc" : "asc"); - } - setPage(1); - } - - const { data, isLoading } = useQuery({ - queryKey: ["medications", medicationId, "intake", page, sortBy, sortDir], - queryFn: async () => { - const params = new URLSearchParams(); - params.set("limit", String(PAGE_SIZE)); - params.set("offset", String((page - 1) * PAGE_SIZE)); - params.set("sortBy", sortBy); - params.set("sortDir", sortDir); - const res = await fetch( - `/api/medications/${medicationId}/intake?${params}`, - ); - if (!res.ok) throw new Error("Failed to fetch"); - const json = await res.json(); - return json.data as { - events: IntakeEvent[]; - meta: { total: number; limit: number; offset: number }; - }; - }, - enabled: isAuthenticated, - }); - - const deleteMutation = useMutation({ - mutationFn: async (eventId: string) => { - const res = await fetch( - `/api/medications/${medicationId}/intake/${eventId}`, - { method: "DELETE" }, - ); - if (!res.ok) throw new Error("Delete failed"); - }, - onSuccess: () => { - void invalidateKeys(queryClient, medicationDependentKeys); - }, - }); - - const updateMutation = useMutation({ - mutationFn: async ({ - eventId, - takenAt, - skipped, - scheduledFor, - }: { - eventId: string; - takenAt: string | null; - skipped: boolean; - scheduledFor: string; - }) => { - const res = await fetch( - `/api/medications/${medicationId}/intake/${eventId}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ takenAt, skipped, scheduledFor }), - }, - ); - const json = await res.json(); - if (!res.ok) throw new Error(json.error ?? "Update failed"); - }, - onSuccess: async () => { - await invalidateKeys(queryClient, medicationDependentKeys); - setEditing(null); - setEditError(null); - }, - onError: (err) => { - setEditError( - err instanceof Error ? err.message : t("medications.saveError"), - ); - }, - }); - - const createMutation = useMutation({ - mutationFn: async ({ - scheduledFor, - takenAt, - skipped, - }: { - scheduledFor: string; - takenAt: string | null; - skipped: boolean; - }) => { - const res = await fetch(`/api/medications/${medicationId}/intake`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ scheduledFor, takenAt, skipped }), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error ?? "Create failed"); - }, - onSuccess: async () => { - await invalidateKeys(queryClient, medicationDependentKeys); - setCreating(false); - setCreateError(null); - }, - onError: (err) => { - setCreateError( - err instanceof Error ? err.message : t("medications.saveError"), - ); - }, - }); - - const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0; - - function startEdit(event: IntakeEvent) { - setEditing(event); - setEditScheduledFor(toDateTimeLocalValue(event.scheduledFor)); - setEditTakenAt(event.takenAt ? toDateTimeLocalValue(event.takenAt) : ""); - setEditSkipped(event.skipped); - setEditError(null); - } - - function closeEdit() { - if (updateMutation.isPending || deleteMutation.isPending) return; - setEditing(null); - setEditError(null); - setEditDeleteDialogOpen(false); - } - - function closeCreate() { - if (createMutation.isPending) return; - setCreating(false); - setCreateError(null); - } - - async function deleteEditingEvent() { - if (!editing) return; - try { - setEditError(null); - await deleteMutation.mutateAsync(editing.id); - closeEdit(); - } catch { - setEditError(t("medications.deleteError")); - } - } - - async function submitEdit(e: React.FormEvent) { - e.preventDefault(); - if (!editing) return; - - const scheduledDate = new Date(editScheduledFor); - if (Number.isNaN(scheduledDate.getTime())) { - setEditError(t("medications.invalidTimestamp")); - return; - } - - setEditError(null); - updateMutation.mutate({ - eventId: editing.id, - scheduledFor: dateTimeLocalToISO(editScheduledFor), - takenAt: - editSkipped || !editTakenAt ? null : dateTimeLocalToISO(editTakenAt), - skipped: editSkipped, - }); - } - - async function submitCreate(e: React.FormEvent) { - e.preventDefault(); - - const scheduledDate = new Date(createScheduledFor); - if (Number.isNaN(scheduledDate.getTime())) { - setCreateError(t("medications.invalidTimestamp")); - return; - } - - setCreateError(null); - createMutation.mutate({ - scheduledFor: dateTimeLocalToISO(createScheduledFor), - takenAt: - createSkipped || !createTakenAt - ? null - : dateTimeLocalToISO(createTakenAt), - skipped: createSkipped, - }); - } - - function getStatusBadge(event: IntakeEvent) { - if (event.skipped) { - return ( - - - {t("medications.intakeStatusSkipped")} - - ); - } - if (event.takenAt) { - return ( - - - {t("medications.intakeStatusTaken")} - - ); - } - return ( - - - {t("medications.intakeStatusMissed")} - - ); - } - - if (!isAuthenticated) { - return ( -

- {t("medications.loginRequiredIntakes")} -

- ); - } - - return ( - <> -
-
- {data?.meta?.total !== undefined && ( - - {t("medications.intakeCount", { count: data.meta.total })} - - )} -
- - {isLoading ? ( -
- -
- ) : !data?.events?.length ? ( -
- {t("medications.noIntakesYet")} -
- ) : ( - <> - {/* Desktop table */} -
- - - - - - - {t("medications.intakeStatus")} - - - - {t("medications.actions")} - - - - - {data.events.map((event) => ( - - - {formatDateTime(event.scheduledFor)} - - - {event.takenAt ? formatDateTime(event.takenAt) : "—"} - - {getStatusBadge(event)} - - - {sourceLabels[event.source] ?? event.source} - - - -
- - deleteMutation.mutate(event.id)} - /> -
-
-
- ))} -
-
-
- - {/* Mobile cards */} -
- {data.events.map((event) => ( -
-
-
-

- {formatDateTime(event.scheduledFor)} -

- {event.takenAt && ( -

- {t("medications.intakeTakenAt")}:{" "} - {formatDateTime(event.takenAt)} -

- )} -
- {getStatusBadge(event)} - - {sourceLabels[event.source] ?? event.source} - -
-
-
- - deleteMutation.mutate(event.id)} - /> -
-
-
- ))} -
- - )} - - {/* Pagination */} - {totalPages > 1 && ( -
- - {t("medications.pageInfo", { page, total: totalPages })} - -
- - -
-
- )} -
- - {/* Edit Sheet */} - !open && closeEdit()} - title={t("medications.editIntake")} - footer={
} - > - {editing && ( - -
- - setEditScheduledFor(e.target.value)} - required - aria-required="true" - aria-invalid={!!editError || undefined} - aria-describedby={editErrorDescriptor} - /> -
- -
- - setEditTakenAt(e.target.value)} - disabled={editSkipped} - aria-invalid={!!editError || undefined} - aria-describedby={editErrorDescriptor} - /> -
- -
- { - setEditSkipped(checked); - if (checked) setEditTakenAt(""); - }} - /> - -
- - {editError && ( - - )} - - {editFooterEl - ? createPortal( -
- - - - - - setEditDeleteDialogOpen(true)} - > - - {t("common.delete")} - - - - -
- - -
-
, - editFooterEl, - ) - : null} - - - - - - {t("medications.intakeDeleteConfirm")} - - - {t("medications.intakeDeleteDescription")} - - - - - {t("common.cancel")} - - - {deleteMutation.isPending ? ( - - ) : null} - {t("common.delete")} - - - - - - )} - - - {/* Create Sheet */} - !open && closeCreate()} - title={t("medications.newIntake")} - footer={
} - > -
-
- - setCreateScheduledFor(e.target.value)} - required - aria-required="true" - aria-invalid={!!createError || undefined} - aria-describedby={createErrorDescriptor} - /> -
- -
- - setCreateTakenAt(e.target.value)} - disabled={createSkipped} - aria-invalid={!!createError || undefined} - aria-describedby={createErrorDescriptor} - /> -
- -
- { - setCreateSkipped(checked); - if (checked) setCreateTakenAt(""); - }} - /> - -
- - {createError && ( - - )} - -
- {createFooterEl - ? createPortal( -
- - -
, - createFooterEl, - ) - : null} - - - ); -} - -function SortableHead({ - column, - label, - currentSort, - currentDir, - onSort, - className, -}: { - column: string; - label: string; - currentSort: string; - currentDir: "asc" | "desc"; - onSort: (col: string) => void; - className?: string; -}) { - const isActive = currentSort === column; - return ( - - - - ); -} - -function DeleteButton({ - label, - description, - onConfirm, -}: { - label: string; - description: string; - onConfirm: () => void; -}) { - const { t } = useTranslations(); - return ( - - - - - - - {label} - {description} - - - {t("common.cancel")} - - {t("common.delete")} - - - - - ); -}