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")} - - - - - ); -}