feat(meds): self-hosted medication detail page restoring every v1.5.4-displaced action

A new `/medications/[id]/page.tsx` surfaces every action the v1.5.4
form retirement buried — log today's dose, edit the plan, manage
notifications, manage API tokens, import CSV, configure GLP-1 phases
in a focused Sheet, walk a three-tier destructive cascade. The page
reads as a clinical document from header to destructive footer; one
phone-scroll reaches every restored feature.

Section order (recurring variant — 8 sections):

  1. Header band — drug name + dose + status pill + edit pencil
  2. Today's dose card — two buttons (`[Genommen]` + `[Übersprungen]`)
  3. Cadence summary — plain-language line + Bearbeiten → wizard
     Step 5 via the new `landingIntent: "cadence"` route
  4. Dose ladder / Phasen — GLP-1 only, reuses the v1.4 components
  5. Intake history preview — last 14 grouped + footer link to
     `/history`; CSV-import trigger lives in the header
  6. Notifications — `<Switch>` in `<label>` row + read-only chip
     strip; replaced by an "iPhone steuert die Erinnerungen" chip
     when `clientManaged === true`
  7. Settings — Externe Integration / CSV stub / Phasen
     (button → Sheet) / Grace minutes
  8. Verwaltung & Gefahrenzone — Card A (Pausieren + Beenden) and
     Card B (Verlauf + Medikament löschen) under one heading

One-shot variant collapses to 5 sections per D-3 §6. Paused variant
keeps the same structure with the header pill flipped and the
today's-dose buttons muted.

Every mutation cascades through `medicationDependentKeys` so the
inline compliance chart, dashboard chart and achievement reads
converge in one tick. Tier 1 Pausieren PUTs `{ active }` only;
the server derives `pausedAt`. Destructive CTAs ship `font-semibold`
so the WCAG large-text contrast band catches the destructive-button
white-on-#ff5555. Status pill text is always rendered; the dot is
`aria-hidden` and reads from Dracula `--success` / `--warning` tokens
so the colour stays in lockstep with the theme.

i18n covers DE + EN natively; ES/FR/IT/PL inherit the EN strings as
the locale-integrity guard's fallback contract specifies. New keys
live under `medications.detail.*`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 23:51:17 +02:00
1 parent 7f95a2a44f
commit 4ae8801c56
17 files changed
+2953 -4

No files matched your search

+112
View File
@@ -8,6 +8,7 @@
"create": "Erstellen",
"close": "Schließen",
"loading": "Laden...",
"loadFailed": "Konnte nicht geladen werden",
"error": "Fehler",
"saved": "Gespeichert",
"no": "Nein",
@@ -1109,6 +1110,117 @@
"gatedCta": "Forschungsmodus in den Einstellungen öffnen",
"unknownDrug": "{name} ist nicht im EMA-zugelassenen GLP-1-Katalog enthalten. HealthLog kann dafür keine Populations-Pharmakokinetik-Kurve erzeugen."
}
},
"detail": {
"status": {
"active": "Aktiv",
"paused": "Pausiert",
"ended": "Beendet"
},
"today": {
"groupLabel": "Heutige Dosis",
"taken": "Genommen",
"skipped": "Übersprungen",
"toastTaken": "Heutige Dosis erfasst",
"toastSkipped": "Heutige Dosis übersprungen",
"recordedTaken": "Heute genommen um {time}",
"recordedSkipped": "Heute übersprungen um {time}",
"recordedOneShot": "Einmalig genommen um {time}",
"noneScheduled": "Heute keine Einnahme geplant.",
"pausedHint": "Pausiert — heute keine Erinnerung.",
"error": "Dosis konnte nicht erfasst werden."
},
"cadence": {
"title": "Rhythmus",
"oneShotOn": "Einmalige Dosis am {date}.",
"oneShotPending": "Einmalige Dosis ausstehend."
},
"intake": {
"title": "Einnahmeverlauf",
"importButton": "Importieren",
"viewAllLink": "Vollständigen Verlauf öffnen →"
},
"notifications": {
"title": "Erinnerungen",
"switchLabel": "Erinnern, wenn die Dosis fällig ist",
"helperOn": "Erinnerungen sind aktiv.",
"helperOff": "Erinnerungen sind aus.",
"enabledToast": "Erinnerungen aktiv",
"disabledToast": "Erinnerungen aus",
"toggleFailed": "Erinnerungs-Einstellung konnte nicht geändert werden.",
"clientManagedChip": "Dein iPhone steuert die Erinnerungen für dieses Medikament."
},
"settings": {
"title": "Einstellungen",
"csvImport": {
"title": "CSV-Import",
"stub": "Importiere Einnahmen direkt aus dem Verlauf oben."
},
"phases": {
"openButton": "Phasen konfigurieren",
"requiresCourseWindow": "Phasen sind nur mit Kurs-Fenster verfügbar."
},
"grace": {
"label": "Erinnerungsfenster — gilt für deinen Hauptzeitplan",
"primaryScheduleNote": "Andere Zeitpläne behalten ihr Standard-Fenster.",
"unit": "Minuten",
"saved": "Erinnerungsfenster gespeichert",
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
}
},
"zone": {
"title": "Verwaltung & Gefahrenzone",
"pause": {
"title": "Pausieren",
"helper": "Erinnerungen anhalten, bis du sie wieder aktivierst. Verlauf bleibt erhalten.",
"pausedToast": "Erinnerungen pausiert",
"resumedToast": "Erinnerungen reaktiviert",
"failed": "Status konnte nicht geändert werden."
},
"end": {
"title": "Medikament beenden",
"helper": "Erinnerungen stoppen und das Medikament als beendet markieren. Verlauf bleibt sichtbar.",
"button": "Beenden",
"dialogTitle": "{name} beenden — Erinnerungen stoppen, Verlauf bleibt sichtbar",
"dialogBody": "Keine weiteren Erinnerungen. Alte Einträge bleiben in der Übersicht.",
"toast": "Medikament beendet",
"failed": "Medikament konnte nicht beendet werden."
},
"purge": {
"title": "Verlauf löschen",
"helper": "Es sind {count} Einnahmen für dieses Medikament gespeichert.",
"button": "Verlauf löschen",
"dialogTitle": "Verlauf wirklich löschen?",
"dialogBody": "Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.",
"toast": "Verlauf gelöscht",
"failed": "Verlauf konnte nicht gelöscht werden."
},
"delete": {
"title": "Medikament löschen",
"helper": "Entfernt das Medikament und alle zugehörigen Daten.",
"button": "Löschen",
"dialogTitle": "{name} löschen?",
"dialogBody": "Medikament, Zeitpläne, Verlauf, API-Tokens, Phasen-Konfiguration und Erinnerungen werden unwiderruflich gelöscht.",
"toast": "Medikament gelöscht",
"failed": "Medikament konnte nicht gelöscht werden."
}
},
"phases": {
"modeLabel": "Modus für {phase}",
"saveFailed": "Phasen konnten nicht gespeichert werden."
},
"api": {
"caption": "Endpunkt für „{name}\"",
"copyUrl": "URL kopieren",
"urlCopied": "URL kopiert",
"mintToken": "Token erzeugen",
"mintAnotherToken": "Weiteres Token erzeugen",
"mintFailed": "Token konnte nicht erzeugt werden.",
"copyFailed": "Kopieren fehlgeschlagen.",
"tokenCopied": "Token kopiert",
"copyToken": "Token kopieren",
"mintedHint": "Token jetzt kopieren — es wird nur einmal angezeigt."
}
}
},
"charts": {
+112
View File
@@ -8,6 +8,7 @@
"create": "Create",
"close": "Close",
"loading": "Loading...",
"loadFailed": "Could not load",
"error": "Error",
"saved": "Saved",
"no": "No",
@@ -1109,6 +1110,117 @@
"gatedCta": "Open Research Mode in Settings",
"unknownDrug": "{name} is not in the EMA-approved GLP-1 catalog, so HealthLog cannot render a population-PK curve for it."
}
},
"detail": {
"status": {
"active": "Active",
"paused": "Paused",
"ended": "Ended"
},
"today": {
"groupLabel": "Today's dose",
"taken": "Taken",
"skipped": "Skipped",
"toastTaken": "Today's dose logged",
"toastSkipped": "Today's dose skipped",
"recordedTaken": "Taken today at {time}",
"recordedSkipped": "Skipped today at {time}",
"recordedOneShot": "One-shot dose taken at {time}",
"noneScheduled": "No dose scheduled for today.",
"pausedHint": "Paused — no reminder today.",
"error": "Could not log the dose."
},
"cadence": {
"title": "Cadence",
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →"
},
"notifications": {
"title": "Notifications",
"switchLabel": "Send a reminder when this dose is due",
"helperOn": "Reminders are on.",
"helperOff": "Reminders are off.",
"enabledToast": "Reminders on",
"disabledToast": "Reminders off",
"toggleFailed": "Could not change the reminder setting.",
"clientManagedChip": "Your iPhone manages reminders for this medication."
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
"label": "Reminder window — applies to your primary schedule",
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
}
},
"zone": {
"title": "Manage & danger zone",
"pause": {
"title": "Pause",
"helper": "Pause reminders until you turn them back on. History stays intact.",
"pausedToast": "Reminders paused",
"resumedToast": "Reminders resumed",
"failed": "Could not change the paused state."
},
"end": {
"title": "End medication",
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
"button": "End",
"dialogTitle": "End {name} — stop reminders, history stays visible",
"dialogBody": "No more reminders. Old entries remain in the timeline.",
"toast": "Medication ended",
"failed": "Could not end the medication."
},
"purge": {
"title": "Delete history",
"helper": "{count} intake events are stored for this medication.",
"button": "Delete history",
"dialogTitle": "Really delete the history?",
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
"toast": "History deleted",
"failed": "Could not delete the history."
},
"delete": {
"title": "Delete medication",
"helper": "Removes the medication and every related record.",
"button": "Delete",
"dialogTitle": "Delete {name}?",
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
"toast": "Medication deleted",
"failed": "Could not delete the medication."
}
},
"phases": {
"modeLabel": "{phase} mode",
"saveFailed": "Could not save the reminder phases."
},
"api": {
"caption": "Endpoint for \"{name}\"",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"mintToken": "Mint token",
"mintAnotherToken": "Mint another token",
"mintFailed": "Could not mint a token.",
"copyFailed": "Could not copy.",
"tokenCopied": "Token copied",
"copyToken": "Copy token",
"mintedHint": "Copy this token now — it is shown only once."
}
}
},
"charts": {
+113 -1
View File
@@ -8,6 +8,7 @@
"create": "Crear",
"close": "Cerrar",
"loading": "Cargando…...",
"loadFailed": "Could not load",
"error": "Error",
"saved": "Guardado",
"no": "No",
@@ -1109,6 +1110,117 @@
"gatedCta": "Abrir el modo Investigación en los ajustes",
"unknownDrug": "{name} no está en el catálogo de GLP-1 aprobado por la EMA, por lo que HealthLog no puede trazar una curva de farmacocinética poblacional."
}
},
"detail": {
"status": {
"active": "Active",
"paused": "Paused",
"ended": "Ended"
},
"today": {
"groupLabel": "Today's dose",
"taken": "Taken",
"skipped": "Skipped",
"toastTaken": "Today's dose logged",
"toastSkipped": "Today's dose skipped",
"recordedTaken": "Taken today at {time}",
"recordedSkipped": "Skipped today at {time}",
"recordedOneShot": "One-shot dose taken at {time}",
"noneScheduled": "No dose scheduled for today.",
"pausedHint": "Paused — no reminder today.",
"error": "Could not log the dose."
},
"cadence": {
"title": "Cadence",
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →"
},
"notifications": {
"title": "Notifications",
"switchLabel": "Send a reminder when this dose is due",
"helperOn": "Reminders are on.",
"helperOff": "Reminders are off.",
"enabledToast": "Reminders on",
"disabledToast": "Reminders off",
"toggleFailed": "Could not change the reminder setting.",
"clientManagedChip": "Your iPhone manages reminders for this medication."
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
"label": "Reminder window — applies to your primary schedule",
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
}
},
"zone": {
"title": "Manage & danger zone",
"pause": {
"title": "Pause",
"helper": "Pause reminders until you turn them back on. History stays intact.",
"pausedToast": "Reminders paused",
"resumedToast": "Reminders resumed",
"failed": "Could not change the paused state."
},
"end": {
"title": "End medication",
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
"button": "End",
"dialogTitle": "End {name} — stop reminders, history stays visible",
"dialogBody": "No more reminders. Old entries remain in the timeline.",
"toast": "Medication ended",
"failed": "Could not end the medication."
},
"purge": {
"title": "Delete history",
"helper": "{count} intake events are stored for this medication.",
"button": "Delete history",
"dialogTitle": "Really delete the history?",
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
"toast": "History deleted",
"failed": "Could not delete the history."
},
"delete": {
"title": "Delete medication",
"helper": "Removes the medication and every related record.",
"button": "Delete",
"dialogTitle": "Delete {name}?",
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
"toast": "Medication deleted",
"failed": "Could not delete the medication."
}
},
"phases": {
"modeLabel": "{phase} mode",
"saveFailed": "Could not save the reminder phases."
},
"api": {
"caption": "Endpoint for \"{name}\"",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"mintToken": "Mint token",
"mintAnotherToken": "Mint another token",
"mintFailed": "Could not mint a token.",
"copyFailed": "Could not copy.",
"tokenCopied": "Token copied",
"copyToken": "Copy token",
"mintedHint": "Copy this token now — it is shown only once."
}
}
},
"charts": {
@@ -3389,4 +3501,4 @@
"dailyTitle": "Registrar el ánimo",
"dailyBody": "¿Cómo te sientes hoy?"
}
}
}
+113 -1
View File
@@ -8,6 +8,7 @@
"create": "Créer",
"close": "Fermer",
"loading": "Chargement…...",
"loadFailed": "Could not load",
"error": "Erreur",
"saved": "Enregistré",
"no": "Non",
@@ -1109,6 +1110,117 @@
"gatedCta": "Ouvrir le mode Recherche dans les paramètres",
"unknownDrug": "{name} n'est pas dans le catalogue GLP-1 approuvé par l'EMA, donc HealthLog ne peut pas tracer de courbe de pharmacocinétique de population."
}
},
"detail": {
"status": {
"active": "Active",
"paused": "Paused",
"ended": "Ended"
},
"today": {
"groupLabel": "Today's dose",
"taken": "Taken",
"skipped": "Skipped",
"toastTaken": "Today's dose logged",
"toastSkipped": "Today's dose skipped",
"recordedTaken": "Taken today at {time}",
"recordedSkipped": "Skipped today at {time}",
"recordedOneShot": "One-shot dose taken at {time}",
"noneScheduled": "No dose scheduled for today.",
"pausedHint": "Paused — no reminder today.",
"error": "Could not log the dose."
},
"cadence": {
"title": "Cadence",
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →"
},
"notifications": {
"title": "Notifications",
"switchLabel": "Send a reminder when this dose is due",
"helperOn": "Reminders are on.",
"helperOff": "Reminders are off.",
"enabledToast": "Reminders on",
"disabledToast": "Reminders off",
"toggleFailed": "Could not change the reminder setting.",
"clientManagedChip": "Your iPhone manages reminders for this medication."
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
"label": "Reminder window — applies to your primary schedule",
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
}
},
"zone": {
"title": "Manage & danger zone",
"pause": {
"title": "Pause",
"helper": "Pause reminders until you turn them back on. History stays intact.",
"pausedToast": "Reminders paused",
"resumedToast": "Reminders resumed",
"failed": "Could not change the paused state."
},
"end": {
"title": "End medication",
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
"button": "End",
"dialogTitle": "End {name} — stop reminders, history stays visible",
"dialogBody": "No more reminders. Old entries remain in the timeline.",
"toast": "Medication ended",
"failed": "Could not end the medication."
},
"purge": {
"title": "Delete history",
"helper": "{count} intake events are stored for this medication.",
"button": "Delete history",
"dialogTitle": "Really delete the history?",
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
"toast": "History deleted",
"failed": "Could not delete the history."
},
"delete": {
"title": "Delete medication",
"helper": "Removes the medication and every related record.",
"button": "Delete",
"dialogTitle": "Delete {name}?",
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
"toast": "Medication deleted",
"failed": "Could not delete the medication."
}
},
"phases": {
"modeLabel": "{phase} mode",
"saveFailed": "Could not save the reminder phases."
},
"api": {
"caption": "Endpoint for \"{name}\"",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"mintToken": "Mint token",
"mintAnotherToken": "Mint another token",
"mintFailed": "Could not mint a token.",
"copyFailed": "Could not copy.",
"tokenCopied": "Token copied",
"copyToken": "Copy token",
"mintedHint": "Copy this token now — it is shown only once."
}
}
},
"charts": {
@@ -3389,4 +3501,4 @@
"dailyTitle": "Enregistrer votre humeur",
"dailyBody": "Comment vous sentez-vous aujourd’hui ?"
}
}
}
+113 -1
View File
@@ -8,6 +8,7 @@
"create": "Crea",
"close": "Chiudi",
"loading": "Caricamento…...",
"loadFailed": "Could not load",
"error": "Errore",
"saved": "Salvato",
"no": "No",
@@ -1109,6 +1110,117 @@
"gatedCta": "Apri la modalità Ricerca nelle impostazioni",
"unknownDrug": "{name} non è nel catalogo GLP-1 approvato dall'EMA, quindi HealthLog non può tracciare una curva di farmacocinetica di popolazione."
}
},
"detail": {
"status": {
"active": "Active",
"paused": "Paused",
"ended": "Ended"
},
"today": {
"groupLabel": "Today's dose",
"taken": "Taken",
"skipped": "Skipped",
"toastTaken": "Today's dose logged",
"toastSkipped": "Today's dose skipped",
"recordedTaken": "Taken today at {time}",
"recordedSkipped": "Skipped today at {time}",
"recordedOneShot": "One-shot dose taken at {time}",
"noneScheduled": "No dose scheduled for today.",
"pausedHint": "Paused — no reminder today.",
"error": "Could not log the dose."
},
"cadence": {
"title": "Cadence",
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →"
},
"notifications": {
"title": "Notifications",
"switchLabel": "Send a reminder when this dose is due",
"helperOn": "Reminders are on.",
"helperOff": "Reminders are off.",
"enabledToast": "Reminders on",
"disabledToast": "Reminders off",
"toggleFailed": "Could not change the reminder setting.",
"clientManagedChip": "Your iPhone manages reminders for this medication."
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
"label": "Reminder window — applies to your primary schedule",
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
}
},
"zone": {
"title": "Manage & danger zone",
"pause": {
"title": "Pause",
"helper": "Pause reminders until you turn them back on. History stays intact.",
"pausedToast": "Reminders paused",
"resumedToast": "Reminders resumed",
"failed": "Could not change the paused state."
},
"end": {
"title": "End medication",
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
"button": "End",
"dialogTitle": "End {name} — stop reminders, history stays visible",
"dialogBody": "No more reminders. Old entries remain in the timeline.",
"toast": "Medication ended",
"failed": "Could not end the medication."
},
"purge": {
"title": "Delete history",
"helper": "{count} intake events are stored for this medication.",
"button": "Delete history",
"dialogTitle": "Really delete the history?",
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
"toast": "History deleted",
"failed": "Could not delete the history."
},
"delete": {
"title": "Delete medication",
"helper": "Removes the medication and every related record.",
"button": "Delete",
"dialogTitle": "Delete {name}?",
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
"toast": "Medication deleted",
"failed": "Could not delete the medication."
}
},
"phases": {
"modeLabel": "{phase} mode",
"saveFailed": "Could not save the reminder phases."
},
"api": {
"caption": "Endpoint for \"{name}\"",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"mintToken": "Mint token",
"mintAnotherToken": "Mint another token",
"mintFailed": "Could not mint a token.",
"copyFailed": "Could not copy.",
"tokenCopied": "Token copied",
"copyToken": "Copy token",
"mintedHint": "Copy this token now — it is shown only once."
}
}
},
"charts": {
@@ -3389,4 +3501,4 @@
"dailyTitle": "Registra il tuo umore",
"dailyBody": "Come ti senti oggi?"
}
}
}
+113 -1
View File
@@ -8,6 +8,7 @@
"create": "Utwórz",
"close": "Zamknij",
"loading": "Ładowanie…...",
"loadFailed": "Could not load",
"error": "Błąd",
"saved": "Zapisano",
"no": "Nie",
@@ -1109,6 +1110,117 @@
"gatedCta": "Otwórz tryb Badawczy w Ustawieniach",
"unknownDrug": "{name} nie znajduje się w katalogu GLP-1 zatwierdzonym przez EMA, więc HealthLog nie może wyrysować krzywej farmakokinetyki populacyjnej."
}
},
"detail": {
"status": {
"active": "Active",
"paused": "Paused",
"ended": "Ended"
},
"today": {
"groupLabel": "Today's dose",
"taken": "Taken",
"skipped": "Skipped",
"toastTaken": "Today's dose logged",
"toastSkipped": "Today's dose skipped",
"recordedTaken": "Taken today at {time}",
"recordedSkipped": "Skipped today at {time}",
"recordedOneShot": "One-shot dose taken at {time}",
"noneScheduled": "No dose scheduled for today.",
"pausedHint": "Paused — no reminder today.",
"error": "Could not log the dose."
},
"cadence": {
"title": "Cadence",
"oneShotOn": "One-time dose on {date}.",
"oneShotPending": "One-time dose pending."
},
"intake": {
"title": "Intake history",
"importButton": "Import",
"viewAllLink": "View full history →"
},
"notifications": {
"title": "Notifications",
"switchLabel": "Send a reminder when this dose is due",
"helperOn": "Reminders are on.",
"helperOff": "Reminders are off.",
"enabledToast": "Reminders on",
"disabledToast": "Reminders off",
"toggleFailed": "Could not change the reminder setting.",
"clientManagedChip": "Your iPhone manages reminders for this medication."
},
"settings": {
"title": "Settings",
"csvImport": {
"title": "CSV import",
"stub": "Import intakes directly from the history above."
},
"phases": {
"openButton": "Configure phases",
"requiresCourseWindow": "Phases are available once a course window is set."
},
"grace": {
"label": "Reminder window — applies to your primary schedule",
"primaryScheduleNote": "Multi-schedule medications keep their other schedules' default window.",
"unit": "minutes",
"saved": "Reminder window saved",
"failed": "Could not save the reminder window."
}
},
"zone": {
"title": "Manage & danger zone",
"pause": {
"title": "Pause",
"helper": "Pause reminders until you turn them back on. History stays intact.",
"pausedToast": "Reminders paused",
"resumedToast": "Reminders resumed",
"failed": "Could not change the paused state."
},
"end": {
"title": "End medication",
"helper": "Stop reminders and mark the medication as ended. History stays visible.",
"button": "End",
"dialogTitle": "End {name} — stop reminders, history stays visible",
"dialogBody": "No more reminders. Old entries remain in the timeline.",
"toast": "Medication ended",
"failed": "Could not end the medication."
},
"purge": {
"title": "Delete history",
"helper": "{count} intake events are stored for this medication.",
"button": "Delete history",
"dialogTitle": "Really delete the history?",
"dialogBody": "The {count} intake events will be removed permanently. The medication itself stays.",
"toast": "History deleted",
"failed": "Could not delete the history."
},
"delete": {
"title": "Delete medication",
"helper": "Removes the medication and every related record.",
"button": "Delete",
"dialogTitle": "Delete {name}?",
"dialogBody": "The medication, its schedules, intake history, API tokens, phase config and reminders will be removed permanently.",
"toast": "Medication deleted",
"failed": "Could not delete the medication."
}
},
"phases": {
"modeLabel": "{phase} mode",
"saveFailed": "Could not save the reminder phases."
},
"api": {
"caption": "Endpoint for \"{name}\"",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"mintToken": "Mint token",
"mintAnotherToken": "Mint another token",
"mintFailed": "Could not mint a token.",
"copyFailed": "Could not copy.",
"tokenCopied": "Token copied",
"copyToken": "Copy token",
"mintedHint": "Copy this token now — it is shown only once."
}
}
},
"charts": {
@@ -3389,4 +3501,4 @@
"dailyTitle": "Zapisz nastrój",
"dailyBody": "Jak się dzisiaj czujesz?"
}
}
}
+392
View File
@@ -0,0 +1,392 @@
"use client";
/**
* v1.5.5 D-3 §6 + §9 — medication detail page.
*
* Replaces the v1.5.4 "form retirement" gap that left every action
* (Pausieren / Beenden / Notifications / API tokens / Phasen / CSV
* import / Verlauf löschen / Medikament löschen) reachable only
* through the list-page kebab — which the kebab didn't actually wire
* up. The detail page surfaces every restored action in section
* order; one phone-scroll reaches every one of them.
*
* Recurring variant: 8 sections (header → today's dose → cadence →
* dose ladder / phases → intake history preview → notifications →
* settings → destructive zone).
*
* One-shot variant: 5 sections (header → today's-dose-or-logged →
* cadence static line → intake history single row → destructive
* zone). Per D-3 §6 the wizard handles cadence edit via the header
* pencil so the cadence row hides its own edit affordance.
*
* Paused variant: same as the recurring/one-shot layout; the header
* status pill flips and the today's-dose card disables both buttons
* with a muted helper. Settings and destructive zone keep rendering.
*
* Reads:
*
* - `medicationDetail(id)` for the medication snapshot.
* - `medicationIntakeList(id, …)` for the last-14-grouped preview
* (the v2 list owns its own paging).
* - `notificationsStatus()` + `authMe()` on the notifications
* section.
* - `medicationTitration(id)` on the GLP-1 dose ladder.
*
* Mutations all cascade through `medicationDependentKeys` (which now
* includes the `compliance-chart-inline` prefix per D-3 §10).
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { MedicationDetailHeader } from "@/components/medications/medication-detail-header";
import { TodaysDoseCard } from "@/components/medications/todays-dose-card";
import { CadenceSummaryRow } from "@/components/medications/cadence-summary-row";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { NotificationsSection } from "@/components/medications/sections/notifications-section";
import { SettingsSection } from "@/components/medications/sections/settings-section";
import { DestructiveZoneSection } from "@/components/medications/sections/destructive-zone-section";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
interface ScheduleSnapshot {
id: string;
windowStart: string;
windowEnd: string;
label: string | null;
dose: string | null;
daysOfWeek: string | null;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
reminderGraceMinutes?: number | null;
}
interface MedicationDetailSnapshot {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
schedules: ScheduleSnapshot[];
}
interface IntakeListResponse {
events: Array<{
id: string;
scheduledFor: string;
takenAt: string | null;
skipped: boolean;
}>;
meta: { total: number; limit: number; offset: number };
}
const INTAKE_PREVIEW_PARAMS = {
sortBy: "takenAt",
sortDir: "desc" as const,
limit: 14,
offset: 0,
status: "completed",
};
function snapshotToWizardPayload(
med: MedicationDetailSnapshot,
): MedicationPayload {
return {
id: med.id,
name: med.name,
dose: med.dose,
category: med.category,
treatmentClass: med.treatmentClass,
notificationsEnabled: med.notificationsEnabled,
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
...parseScheduleRecurrence(s.daysOfWeek),
timesOfDay: s.timesOfDay,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
})),
};
}
function isToday(iso: string): boolean {
const d = new Date(iso);
const today = new Date();
return (
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate()
);
}
export default function MedicationDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
const { t } = useTranslations();
const formatters = useFormatters();
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardIntent, setWizardIntent] = useState<
"cadence" | "name" | undefined
>(undefined);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [authLoading, isAuthenticated, router]);
const {
data: medication,
isLoading,
isError,
} = useQuery<MedicationDetailSnapshot>({
queryKey: queryKeys.medicationDetail(id),
queryFn: async () => {
const res = await fetch(`/api/medications/${id}`);
if (!res.ok) throw new Error("medication_detail_failed");
return (await res.json()).data as MedicationDetailSnapshot;
},
enabled: isAuthenticated,
});
const { data: intakeList } = useQuery<IntakeListResponse>({
queryKey: queryKeys.medicationIntakeList(id, INTAKE_PREVIEW_PARAMS),
queryFn: async () => {
const params = new URLSearchParams({
sortBy: INTAKE_PREVIEW_PARAMS.sortBy,
sortDir: INTAKE_PREVIEW_PARAMS.sortDir,
limit: String(INTAKE_PREVIEW_PARAMS.limit),
offset: String(INTAKE_PREVIEW_PARAMS.offset),
status: INTAKE_PREVIEW_PARAMS.status,
});
const res = await fetch(
`/api/medications/${id}/intake?${params.toString()}`,
);
if (!res.ok) throw new Error("intake_list_failed");
return (await res.json()).data as IntakeListResponse;
},
enabled: isAuthenticated && !!medication,
});
if (authLoading || isLoading) {
return (
<div
className="flex h-64 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
/>
</div>
);
}
if (isError || !medication) {
return (
<div className="space-y-6">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground -ml-2 gap-1"
asChild
>
<Link href="/medications">
<ArrowLeft className="h-4 w-4" />
{t("medications.back")}
</Link>
</Button>
<Card
className="p-6"
role="alert"
aria-live="polite"
data-slot="medication-detail-error-card"
>
<p className="text-destructive text-sm">
{t("medications.loadFailed")}
</p>
</Card>
</div>
);
}
const oneShot = medication.oneShot === true;
const todayEvent = intakeList?.events.find(
(e) =>
(e.takenAt && isToday(e.takenAt)) ||
(e.skipped && isToday(e.scheduledFor)),
);
const intakeCount = intakeList?.meta.total ?? 0;
const primaryGrace =
medication.schedules[0]?.reminderGraceMinutes ?? null;
function openWizardWithIntent(intent: "cadence" | "name") {
setWizardIntent(intent);
setWizardOpen(true);
}
function closeWizard() {
setWizardOpen(false);
setWizardIntent(undefined);
}
return (
<div className="space-y-6" data-slot="medication-detail-page">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground -ml-2 gap-1"
asChild
>
<Link href="/medications">
<ArrowLeft aria-hidden="true" className="h-4 w-4" />
{t("medications.back")}
</Link>
</Button>
{/* §9.1 — Header band */}
<MedicationDetailHeader
name={medication.name}
dose={medication.dose}
active={medication.active}
endsOn={medication.endsOn}
onEdit={() => openWizardWithIntent("name")}
/>
{/* §9.2 — Today's dose card */}
<TodaysDoseCard
medicationId={id}
active={medication.active}
oneShot={oneShot}
scheduledForToday={!oneShot || !todayEvent}
alreadyTakenAt={
todayEvent && !todayEvent.skipped ? todayEvent.takenAt : null
}
alreadySkipped={Boolean(todayEvent?.skipped)}
/>
{/* §9.3 — Cadence summary. One-shot: static line; the
`hideEdit` flag suppresses the row's own edit affordance
because the header pencil routes the wizard. */}
{oneShot ? (
<Card
className="p-5 sm:p-6"
data-slot="medication-detail-one-shot-cadence"
>
<p className="text-foreground text-sm">
{medication.startsOn
? t("medications.detail.cadence.oneShotOn", {
date: formatters.dateTime(medication.startsOn),
})
: t("medications.detail.cadence.oneShotPending")}
</p>
</Card>
) : (
<CadenceSummaryRow
medication={snapshotToWizardPayload(medication)}
onEdit={() => openWizardWithIntent("cadence")}
/>
)}
{/* §9.4 — Dose ladder / Phasen (GLP-1 only, recurring path
only — one-shot variant drops this) */}
{!oneShot && medication.treatmentClass === "GLP1" && (
<>
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
<TitrationSection medicationId={id} />
</>
)}
{/* §9.5 — Intake history preview */}
<IntakeHistoryPreview
medicationId={id}
importOpen={importOpen}
onImportOpenChange={setImportOpen}
/>
{/* §9.6 + §9.7 — Notifications + Settings: recurring path only. */}
{!oneShot && (
<>
<NotificationsSection
medicationId={id}
notificationsEnabled={medication.notificationsEnabled}
/>
<Separator className="opacity-0" />
<SettingsSection
medicationId={id}
medicationName={medication.name}
treatmentClass={medication.treatmentClass}
startsOn={medication.startsOn}
endsOn={medication.endsOn}
reminderGraceMinutes={primaryGrace}
/>
</>
)}
{/* §9.8 — Verwaltung & Gefahrenzone */}
<DestructiveZoneSection
medicationId={id}
medicationName={medication.name}
active={medication.active}
intakeCount={intakeCount}
/>
{/* Wizard mount — edit-only on the detail page. Intent flips
between "name" (header pencil → Step 1) and "cadence"
(cadence row pencil → Step 5). Cancel returns to the detail
page (the success branch refreshes the medication detail
via the `medicationDependentKeys` cascade). */}
<MedicationWizardDialog
open={wizardOpen}
onOpenChange={(open) => {
if (!open) closeWizard();
}}
mode="edit"
initial={snapshotToWizardPayload(medication)}
landingIntent={wizardIntent}
onSuccess={() => closeWizard()}
/>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
/**
* v1.5.5 D-3 §9.3 — Cadence summary row.
*
* Renders the medication's cadence as a plain-language line (e.g.
* "Alle 12 h nach letzter Einnahme", "Alle 2 Wochen, mittwochs",
* "Monatlich am 15.") with an optional course-window sub-line, and an
* edit pencil that opens the wizard with `landingIntent: "cadence"`.
* For one-shot medications the line collapses to
* `Einmalig am DD.MM.` and the edit affordance is suppressed (the
* header pencil owns the wizard route per D-3 §6 one-shot variant).
*
* The DOM order is `<h2> → <p> → edit`. The button comes last so a
* screen-reader tab walk announces the title and the line before the
* affordance (C-E4-3).
*/
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import {
hydrateWizardPayload,
summariseCadence,
type MedicationPayload,
} from "@/components/medications/wizard/wizard-payload";
import { useTranslations } from "@/lib/i18n/context";
export interface CadenceSummaryRowProps {
medication: MedicationPayload;
/** Hides the edit pencil — the one-shot variant relies on the header. */
hideEdit?: boolean;
onEdit: () => void;
}
export function CadenceSummaryRow({
medication,
hideEdit = false,
onEdit,
}: CadenceSummaryRowProps) {
const { t } = useTranslations();
const payload = hydrateWizardPayload(medication);
const line = summariseCadence(payload, t);
return (
<MedicationDetailSection
titleId="medication-detail-cadence-heading"
title={t("medications.detail.cadence.title")}
dataSlot="medication-detail-cadence-section"
headerExtras={
hideEdit ? null : (
<Button
variant="outline"
size="sm"
onClick={onEdit}
className="min-h-11 sm:min-h-9"
data-slot="cadence-summary-edit"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
)
}
>
<p className="text-sm" data-slot="cadence-summary-line">
{line}
</p>
</MedicationDetailSection>
);
}
@@ -0,0 +1,113 @@
"use client";
/**
* v1.5.5 D-3 §9.1 — medication-detail page header band.
*
* Renders the drug name as the H1, dose as a muted sub-line, and a
* status row carrying a Dracula-token dot + always-visible label
* (Aktiv / Pausiert / Beendet) and an edit pencil that opens the
* wizard with the `"name"` intent (Step 1 landing). The DOM order is
* `name → dose → status → edit` so the screen reader announces the
* drug first and the affordance last (C-E4-3).
*
* The status pill text is always rendered; the dot carries
* `aria-hidden="true"` and reads from Dracula tokens via
* `bg-[hsl(var(--success))]` / `bg-[hsl(var(--warning))]` /
* `bg-muted-foreground`. The "since DD.MM." drift the earlier draft
* mixed into the header is dropped — lifecycle dates live on the
* destructive zone, not on the header (E-2 M-1).
*/
import { Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
export interface MedicationDetailHeaderProps {
name: string;
dose: string;
active: boolean;
endsOn?: string | null;
/**
* Fires when the user taps the Bearbeiten pencil. The detail page
* routes this to the wizard with `landingIntent: "name"` so the
* user lands on Step 1.
*/
onEdit: () => void;
}
type Status = "active" | "paused" | "ended";
function resolveStatus(active: boolean, endsOn?: string | null): Status {
// `endsOn` set + in the past = the medication is archived. The
// server flips `active=false` in that case but a paused medication
// also reads false; distinguish here so the pill carries the right
// label.
if (endsOn) {
const end = new Date(endsOn);
if (!Number.isNaN(end.getTime()) && end.getTime() <= Date.now()) {
return "ended";
}
}
return active ? "active" : "paused";
}
export function MedicationDetailHeader({
name,
dose,
active,
endsOn,
onEdit,
}: MedicationDetailHeaderProps) {
const { t } = useTranslations();
const status = resolveStatus(active, endsOn);
const statusLabel =
status === "active"
? t("medications.detail.status.active")
: status === "paused"
? t("medications.detail.status.paused")
: t("medications.detail.status.ended");
const dotClass =
status === "active"
? "bg-[hsl(var(--success))]"
: status === "paused"
? "bg-[hsl(var(--warning))]"
: "bg-muted-foreground";
return (
<div
className="flex items-start justify-between gap-3"
data-slot="medication-detail-header"
>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{name}</h1>
<p className="text-muted-foreground text-sm">{dose}</p>
<div
className="text-muted-foreground flex items-center gap-2 text-xs"
data-slot="medication-detail-status-row"
>
<Badge variant="secondary" className="gap-1.5">
<span
aria-hidden="true"
className={`inline-block h-2 w-2 rounded-full ${dotClass}`}
/>
{statusLabel}
</Badge>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={onEdit}
className="min-h-11 sm:min-h-9"
data-slot="medication-detail-edit-button"
>
<Pencil aria-hidden="true" className="h-4 w-4" />
<span>{t("common.edit")}</span>
</Button>
</div>
);
}
@@ -0,0 +1,205 @@
"use client";
/**
* v1.5.5 D-3 §9.7 — Settings → Externe Integration sub-row.
*
* Wraps the v1.5.4 `ApiEndpointDialog` body into a settings-row shape.
* The row mounts unconditionally so every medication can be reached
* over the external API. Caption above the URL reads
* `Endpunkt für „{name}"`; two buttons sit below — `[URL kopieren]`
* and `[Token erzeugen]`. When the user mints a token the value
* surfaces in a one-shot modal that auto-closes after copy (D-3 §7
* Feature 6).
*
* Cache: invalidates `tokens()` + `medicationDependentKeys` on every
* mint / disable so the Tokens settings page reflects the new state
* without a manual refresh.
*/
import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Copy, KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
export interface ApiTokensRowProps {
medicationId: string;
medicationName: string;
}
interface ApiEndpointStatus {
enabled: boolean;
activeTokenCount: number;
}
export function ApiTokensRow({
medicationId,
medicationName,
}: ApiTokensRowProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [mintedToken, setMintedToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Per-medication api-endpoint status reads under a dedicated key
// that rides the `["medications", id, …]` prefix so the bundle
// invalidation in `medicationDependentKeys` catches it.
const queryKey = useMemo(
() => ["medications", medicationId, "api-endpoint"] as const,
[medicationId],
);
const { data: status } = useQuery<ApiEndpointStatus>({
queryKey,
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/api-endpoint`);
if (!res.ok) throw new Error("status_failed");
const json = await res.json();
return {
enabled: json.data.enabled === true,
activeTokenCount: json.data.activeTokenCount ?? 0,
};
},
staleTime: 0,
});
const enabled = status?.enabled ?? false;
const baseUrl =
typeof window !== "undefined" ? window.location.origin : "https://...";
const endpoint = `${baseUrl}/api/ingest/medication`;
async function copy(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(label);
} catch {
toast.error(t("medications.detail.api.copyFailed"));
}
}
async function mintToken() {
if (busy) return;
setBusy(true);
try {
const res = await fetch(`/api/medications/${medicationId}/api-endpoint`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
const json = await res.json();
if (!res.ok) {
toast.error(json.error ?? t("medications.detail.api.mintFailed"));
return;
}
if (json.data.token) {
setMintedToken(json.data.token);
// Auto-copy on mint so the user has it without an extra tap.
try {
await navigator.clipboard.writeText(json.data.token);
} catch {
// Clipboard rejection is silent — the value is still visible
// in the read-only one-shot panel below.
}
}
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKeys.tokens(),
]);
queryClient.setQueryData<ApiEndpointStatus>(queryKey, {
enabled: json.data.enabled === true,
activeTokenCount:
typeof json.data.activeTokenCount === "number"
? json.data.activeTokenCount
: (status?.activeTokenCount ?? 0) + 1,
});
} catch {
toast.error(t("medications.detail.api.mintFailed"));
} finally {
setBusy(false);
}
}
// Auto-clear the minted token after 30 s so the value never lingers
// in the DOM longer than needed.
useEffect(() => {
if (!mintedToken) return;
const id = setTimeout(() => setMintedToken(null), 30_000);
return () => clearTimeout(id);
}, [mintedToken]);
return (
<div
className="space-y-2"
data-slot="medication-detail-api-tokens-row"
>
<p className="text-muted-foreground text-xs">
{t("medications.detail.api.caption", { name: medicationName })}
</p>
<code
className="bg-muted block rounded px-3 py-2 font-mono text-xs break-all"
data-slot="api-endpoint-url"
>
POST {endpoint}
</code>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void copy(endpoint, t("medications.detail.api.urlCopied"))}
className="min-h-11 sm:min-h-9"
data-slot="api-tokens-copy-url"
>
<Copy aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.api.copyUrl")}
</Button>
<Button
size="sm"
onClick={() => void mintToken()}
disabled={busy}
aria-busy={busy || undefined}
className="min-h-11 sm:min-h-9"
data-slot="api-tokens-mint"
>
<KeyRound aria-hidden="true" className="h-4 w-4" />
{enabled
? t("medications.detail.api.mintAnotherToken")
: t("medications.detail.api.mintToken")}
</Button>
</div>
{mintedToken && (
<div
className="border-border bg-muted/50 mt-2 space-y-1.5 rounded-md border p-3"
role="status"
aria-live="polite"
data-slot="api-tokens-minted"
>
<p className="text-foreground text-xs font-medium">
{t("medications.detail.api.mintedHint")}
</p>
<code className="bg-background block rounded px-2 py-1.5 font-mono text-xs break-all">
{mintedToken}
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
void copy(mintedToken, t("medications.detail.api.tokenCopied"))
}
className="h-auto px-1 py-1 text-xs"
>
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
{t("medications.detail.api.copyToken")}
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,413 @@
"use client";
/**
* v1.5.5 D-3 §9.8 — Verwaltung & Gefahrenzone.
*
* One heading, two cards under it. Card A (`Verwaltung`) carries the
* reversible actions (Pausieren + Beenden); Card B (`Gefahrenzone`,
* `border-destructive/40`) carries the irreversible ones (Verlauf
* löschen + Medikament löschen).
*
* Mutation contracts per D-3 §3:
*
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
* `pausedAt`. Switch wraps in `<label>` so the entire row is the
* AT hit target.
* - Tier 2 Beenden: PUT `{ endsOn: <today-iso> }`. AlertDialog
* confirm; Cancel autofocus stays default.
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
* `intakeCount === 0`. AlertDialog confirm.
* - Tier 3b Medikament löschen: DELETE `/api/medications/[id]`.
* AlertDialog confirm. Success cascades through
* `medicationDependentKeys` and routes back to `/medications`.
*
* Every destructive CTA carries `font-semibold` so the WCAG Large
* Text contrast band catches the white-on-#ff5555 destructive button
* (H-cluster-I).
*/
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Archive, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface DestructiveZoneSectionProps {
medicationId: string;
medicationName: string;
active: boolean;
intakeCount: number;
}
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
const PAUSE_HELPER_ID = "medication-detail-pause-helper";
export function DestructiveZoneSection({
medicationId,
medicationName,
active,
intakeCount,
}: DestructiveZoneSectionProps) {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const [paused, setPaused] = useState(!active);
const [tier1Busy, setTier1Busy] = useState(false);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [tier2Busy, setTier2Busy] = useState(false);
const [purgeDialogOpen, setPurgeDialogOpen] = useState(false);
const [tier3aBusy, setTier3aBusy] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tier3bBusy, setTier3bBusy] = useState(false);
async function tier1Toggle(pauseNext: boolean) {
if (tier1Busy) return;
setTier1Busy(true);
const previous = paused;
setPaused(pauseNext);
try {
// v1.5.5 C-E3-2 — body carries `{ active }` only; server derives
// pausedAt. No client-side pausedAt literal.
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !pauseNext }),
});
if (!res.ok) {
setPaused(previous);
toast.error(t("medications.detail.zone.pause.failed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
pauseNext
? t("medications.detail.zone.pause.pausedToast")
: t("medications.detail.zone.pause.resumedToast"),
);
} catch {
setPaused(previous);
toast.error(t("medications.detail.zone.pause.failed"));
} finally {
setTier1Busy(false);
}
}
async function tier2End() {
if (tier2Busy) return;
setTier2Busy(true);
try {
const today = new Date().toISOString();
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endsOn: today }),
});
if (!res.ok) {
toast.error(t("medications.detail.zone.end.failed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
} catch {
toast.error(t("medications.detail.zone.end.failed"));
} finally {
setTier2Busy(false);
}
}
async function tier3aPurge() {
if (tier3aBusy) return;
setTier3aBusy(true);
try {
const res = await fetch(
`/api/medications/${medicationId}/intake/purge`,
{ method: "DELETE" },
);
if (!res.ok) {
toast.error(t("medications.detail.zone.purge.failed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zone.purge.toast"));
setPurgeDialogOpen(false);
} catch {
toast.error(t("medications.detail.zone.purge.failed"));
} finally {
setTier3aBusy(false);
}
}
async function tier3bDelete() {
if (tier3bBusy) return;
setTier3bBusy(true);
try {
const res = await fetch(`/api/medications/${medicationId}`, {
method: "DELETE",
});
if (!res.ok) {
toast.error(t("medications.detail.zone.delete.failed"));
return;
}
// Cache cascade first so the list page paints without the
// medication, then route. The bundle includes
// `compliance-chart-inline` so the inline chart's stale cache
// evicts before the user lands on the list.
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.zone.delete.toast"));
router.push("/medications");
} catch {
toast.error(t("medications.detail.zone.delete.failed"));
setTier3bBusy(false);
}
}
return (
<MedicationDetailSection
titleId="medication-detail-zone-heading"
title={t("medications.detail.zone.title")}
dataSlot="medication-detail-zone-section"
>
<div className="space-y-4">
{/* Card A — Verwaltung (reversible) */}
<Card className="p-4 space-y-4" data-slot="destructive-zone-card-a">
<label
htmlFor={PAUSE_SWITCH_ID}
className="flex items-center justify-between gap-3"
>
<span className="space-y-1">
<span
id={PAUSE_TITLE_ID}
className="text-foreground block text-sm font-medium"
>
{t("medications.detail.zone.pause.title")}
</span>
<span
id={PAUSE_HELPER_ID}
className="text-muted-foreground block text-xs"
>
{t("medications.detail.zone.pause.helper")}
</span>
</span>
<Switch
id={PAUSE_SWITCH_ID}
checked={paused}
disabled={tier1Busy}
onCheckedChange={(checked) => void tier1Toggle(checked)}
aria-labelledby={PAUSE_TITLE_ID}
aria-describedby={PAUSE_HELPER_ID}
/>
</label>
<Separator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.zone.end.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zone.end.helper")}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setEndDialogOpen(true)}
className="min-h-11 sm:min-h-9 font-semibold"
data-slot="destructive-zone-end"
>
<Archive aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.zone.end.button")}
</Button>
</div>
</Card>
{/* Card B — Gefahrenzone (irreversible) */}
<Card
className="border-destructive/40 p-4 space-y-4"
data-slot="destructive-zone-card-b"
>
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.zone.purge.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zone.purge.helper", { count: intakeCount })}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setPurgeDialogOpen(true)}
disabled={intakeCount === 0}
className="min-h-11 sm:min-h-9 font-semibold"
data-slot="destructive-zone-purge"
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.zone.purge.button")}
</Button>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.zone.delete.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zone.delete.helper")}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteDialogOpen(true)}
className="min-h-11 sm:min-h-9 font-semibold"
data-slot="destructive-zone-delete"
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.zone.delete.button")}
</Button>
</div>
</Card>
</div>
{/* Tier 2 — Beenden confirm */}
<AlertDialog open={endDialogOpen} onOpenChange={setEndDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.zone.end.dialogTitle", {
name: medicationName,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.zone.end.dialogBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void tier2End();
}}
disabled={tier2Busy}
aria-busy={tier2Busy || undefined}
className="font-semibold"
>
{tier2Busy && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.zone.end.button")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Tier 3a — Verlauf purge confirm */}
<AlertDialog open={purgeDialogOpen} onOpenChange={setPurgeDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.zone.purge.dialogTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.zone.purge.dialogBody", {
count: intakeCount,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void tier3aPurge();
}}
disabled={tier3aBusy}
aria-busy={tier3aBusy || undefined}
className="font-semibold"
>
{tier3aBusy && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.zone.purge.button")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Tier 3b — Medikament delete confirm */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.zone.delete.dialogTitle", {
name: medicationName,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.zone.delete.dialogBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void tier3bDelete();
}}
disabled={tier3bBusy}
aria-busy={tier3bBusy || undefined}
className="font-semibold"
>
{tier3bBusy && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.zone.delete.button")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</MedicationDetailSection>
);
}
@@ -0,0 +1,80 @@
"use client";
/**
* v1.5.5 D-3 §9.5 — Intake-history preview.
*
* Section chrome via `<MedicationDetailSection>`. Header CTA carries
* the only CSV-import trigger in the app (the v1.5.4 per-card kebab
* went away). The body re-uses `<IntakeHistoryListV2>` so the v1.5.4
* table + sort + pagination stay byte-identical; the preview adds a
* footer link to the full `/medications/[id]/history` route.
*
* Phase-2 add-ons (multi-select bulk-delete + row kebab Bearbeiten /
* Löschen) ride on top of the wrapped list. The bulk-delete uses the
* v1.5.5 `POST /api/medications/[id]/intake/bulk-delete` endpoint and
* fires `medicationDependentKeys` so the inline compliance tile +
* rollup-tier dashboard chart converge in one tick.
*/
import Link from "next/link";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IntakeHistoryListV2 } from "@/components/medications/intake-history-list-v2";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { useTranslations } from "@/lib/i18n/context";
export interface IntakeHistoryPreviewProps {
medicationId: string;
importOpen: boolean;
onImportOpenChange: (open: boolean) => void;
}
export function IntakeHistoryPreview({
medicationId,
importOpen,
onImportOpenChange,
}: IntakeHistoryPreviewProps) {
const { t } = useTranslations();
return (
<>
<MedicationDetailSection
titleId="medication-detail-intake-history-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-detail-intake-history-section"
headerExtras={
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
}
>
<div className="space-y-3" data-slot="intake-history-preview-body">
<IntakeHistoryListV2 medicationId={medicationId} pageSize={14} />
<p className="text-right text-xs">
<Link
href={`/medications/${medicationId}/history`}
className="text-primary inline-flex min-h-11 items-center underline-offset-4 hover:underline sm:min-h-9"
data-slot="intake-history-full-link"
>
{t("medications.detail.intake.viewAllLink")}
</Link>
</p>
</div>
</MedicationDetailSection>
<IntakeImportDialog
medicationId={importOpen ? medicationId : null}
onClose={() => onImportOpenChange(false)}
/>
</>
);
}
@@ -0,0 +1,203 @@
"use client";
/**
* v1.5.5 D-3 §9.6 — Notifications section.
*
* Single Switch wrapped in a `<label>` so the entire row is the AT hit
* target (H-cluster-H + §10 invariant 4). The switch flips
* `medication.notificationsEnabled` via the parent route's
* `PUT /api/medications/[id]` with `{ notificationsEnabled }`. State
* persists optimistically — the parent cache update lands in the same
* paint as the toast (E-4 C-1).
*
* When the user has opted in to `notificationPrefs.medication.clientManaged`
* (the iOS-managed-reminders path) the switch is replaced by a
* read-only chip `iPhone steuert die Erinnerungen`. The clientManaged
* value rides on the `auth/me` user prefs read.
*
* The chip strip below the switch is decorative (`aria-hidden="true"`
* per §10 invariant 12) and surfaces the global notification channels
* the medication reminder will reach when enabled.
*/
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
export interface NotificationsSectionProps {
medicationId: string;
notificationsEnabled: boolean;
}
interface NotificationChannel {
type: string;
enabled?: boolean;
status?: string;
}
interface NotificationStatusResponse {
channels: NotificationChannel[];
}
interface UserPrefsResponse {
notificationPrefs?: {
medication?: { clientManaged?: boolean };
};
}
const SWITCH_ID = "medication-detail-notifications-switch";
const TITLE_ID = "medication-detail-notifications-title";
const HELPER_ID = "medication-detail-notifications-helper";
export function NotificationsSection({
medicationId,
notificationsEnabled,
}: NotificationsSectionProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [localEnabled, setLocalEnabled] = useState(notificationsEnabled);
const [submitting, setSubmitting] = useState(false);
// Dual-source read per §9.6 — the per-medication value lives on the
// medication record, the global channel strip lives on the
// notifications-status endpoint. Both feed the same section so the
// user can confirm in one glance.
const { data: status } = useQuery<NotificationStatusResponse>({
queryKey: queryKeys.notificationsStatus(),
queryFn: async () => {
const res = await fetch("/api/notifications/status");
if (!res.ok) throw new Error("status_failed");
return (await res.json()).data as NotificationStatusResponse;
},
enabled: isAuthenticated,
staleTime: 60_000,
});
const { data: prefs } = useQuery<UserPrefsResponse>({
queryKey: queryKeys.authMe(),
queryFn: async () => {
const res = await fetch("/api/auth/me");
if (!res.ok) throw new Error("auth_failed");
return (await res.json()).data as UserPrefsResponse;
},
enabled: isAuthenticated,
staleTime: 60_000,
});
const clientManaged =
prefs?.notificationPrefs?.medication?.clientManaged === true;
async function flip(next: boolean) {
if (submitting) return;
setSubmitting(true);
const previous = localEnabled;
setLocalEnabled(next);
try {
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notificationsEnabled: next }),
});
if (!res.ok) {
setLocalEnabled(previous);
toast.error(t("medications.detail.notifications.toggleFailed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
next
? t("medications.detail.notifications.enabledToast")
: t("medications.detail.notifications.disabledToast"),
);
} catch {
setLocalEnabled(previous);
toast.error(t("medications.detail.notifications.toggleFailed"));
} finally {
setSubmitting(false);
}
}
return (
<MedicationDetailSection
titleId={TITLE_ID}
title={t("medications.detail.notifications.title")}
dataSlot="medication-detail-notifications-section"
>
<div className="space-y-3">
{clientManaged ? (
// v1.5.5 §9.6 — iPhone-managed path. The switch is hidden;
// the read-only chip tells the user where the toggle lives.
<p
className="text-foreground text-sm"
data-slot="notifications-client-managed-chip"
>
<Badge variant="outline" className="font-normal">
{t("medications.detail.notifications.clientManagedChip")}
</Badge>
</p>
) : (
<label
htmlFor={SWITCH_ID}
className="flex items-center justify-between gap-3"
data-slot="notifications-switch-row"
>
<span className="space-y-1">
<span
id={TITLE_ID}
className="text-foreground block text-sm font-medium"
>
{t("medications.detail.notifications.switchLabel")}
</span>
<span
id={HELPER_ID}
className="text-muted-foreground block text-xs"
>
{localEnabled
? t("medications.detail.notifications.helperOn")
: t("medications.detail.notifications.helperOff")}
</span>
</span>
<Switch
id={SWITCH_ID}
checked={localEnabled}
disabled={submitting}
onCheckedChange={(checked) => void flip(checked)}
aria-labelledby={TITLE_ID}
aria-describedby={HELPER_ID}
/>
</label>
)}
{status && status.channels.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
aria-hidden="true"
data-slot="notifications-channels-chips"
>
{status.channels.map((channel) => (
<Badge
key={channel.type}
variant="outline"
className="text-xs"
>
{channel.type}
</Badge>
))}
</div>
)}
</div>
</MedicationDetailSection>
);
}
@@ -0,0 +1,322 @@
"use client";
/**
* v1.5.5 D-3 §9.7 / C-E3-1 — Phase-config editor inside a focused
* `<ResponsiveSheet>`.
*
* Resurrects the v1.5.4 `{ medicationId, open, onOpenChange }` contract
* but inside a Sheet (mobile bottom / desktop right) instead of inline
* on the settings row. Mounts only when the parent confirms
* `treatmentClass === "GLP1"` + a course window is set.
*
* Body hosts the four-row green/yellow/orange/red grid with mode
* (MINUTES / PERCENT) and value inputs, an `[Auf Standard]` reset, and
* the `[Speichern]` save. The save invalidates
* `medicationPhaseConfig(id)` + `medicationDependentKeys` then closes.
*
* The sheet hosts ONLY the editor form. Per C-E4-4 a row-edit Sheet
* never carries a destructive action; the only buttons here are
* "reset to default" (non-destructive — it stays in the form before
* the user saves) and "save".
*
* State pattern: the editor form is keyed by the server snapshot so a
* fresh server fetch (after the parent invalidates the bundle)
* replaces local edits with the canonical state. This sidesteps the
* React-19 "setState in effect" lint rule.
*/
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
type PhaseMode = "MINUTES" | "PERCENT";
interface PhaseRow {
value: number;
mode: PhaseMode;
}
interface PhaseConfig {
green: PhaseRow;
yellow: PhaseRow;
orange: PhaseRow;
red: PhaseRow;
}
const DEFAULT_CONFIG: PhaseConfig = {
green: { value: 60, mode: "MINUTES" },
yellow: { value: 30, mode: "MINUTES" },
orange: { value: 0, mode: "MINUTES" },
red: { value: 240, mode: "MINUTES" },
};
const PHASE_KEYS: Array<keyof PhaseConfig> = [
"green",
"yellow",
"orange",
"red",
];
export interface PhaseConfigSheetProps {
medicationId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface PhaseConfigApiPayload {
greenValue: number;
greenMode: PhaseMode;
yellowValue: number;
yellowMode: PhaseMode;
orangeValue: number;
orangeMode: PhaseMode;
redValue: number;
redMode: PhaseMode;
}
function fromApi(payload: PhaseConfigApiPayload): PhaseConfig {
return {
green: { value: payload.greenValue, mode: payload.greenMode },
yellow: { value: payload.yellowValue, mode: payload.yellowMode },
orange: { value: payload.orangeValue, mode: payload.orangeMode },
red: { value: payload.redValue, mode: payload.redMode },
};
}
function toApi(cfg: PhaseConfig): PhaseConfigApiPayload {
return {
greenValue: cfg.green.value,
greenMode: cfg.green.mode,
yellowValue: cfg.yellow.value,
yellowMode: cfg.yellow.mode,
orangeValue: cfg.orange.value,
orangeMode: cfg.orange.mode,
redValue: cfg.red.value,
redMode: cfg.red.mode,
};
}
function snapshotKey(cfg: PhaseConfig): string {
return [
cfg.green.mode,
cfg.green.value,
cfg.yellow.mode,
cfg.yellow.value,
cfg.orange.mode,
cfg.orange.value,
cfg.red.mode,
cfg.red.value,
].join("|");
}
export function PhaseConfigSheet({
medicationId,
open,
onOpenChange,
}: PhaseConfigSheetProps) {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.medicationPhaseConfig(medicationId),
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/phase-config`);
if (!res.ok) throw new Error("phase_config_failed");
return fromApi((await res.json()).data as PhaseConfigApiPayload);
},
enabled: open,
staleTime: 0,
});
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.phaseConfig")}
description={t("medications.phaseConfigDescription")}
>
{isLoading ? (
<div
className="flex h-32 items-center justify-center"
role="status"
aria-busy="true"
aria-live="polite"
>
<Loader2
aria-hidden="true"
className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none"
/>
</div>
) : isError ? (
<p
className="text-destructive text-sm"
role="alert"
data-slot="phase-config-error"
>
{t("common.loadFailed")}
</p>
) : data ? (
<PhaseConfigForm
key={snapshotKey(data)}
medicationId={medicationId}
initial={data}
onClose={() => onOpenChange(false)}
/>
) : null}
</ResponsiveSheet>
);
}
function PhaseConfigForm({
medicationId,
initial,
onClose,
}: {
medicationId: string;
initial: PhaseConfig;
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [cfg, setCfg] = useState<PhaseConfig>(initial);
const [saving, setSaving] = useState(false);
const labels = useMemo(
() => ({
green: t("medications.phaseGreen"),
yellow: t("medications.phaseYellow"),
orange: t("medications.phaseOrange"),
red: t("medications.phaseRed"),
}),
[t],
);
function patch(key: keyof PhaseConfig, partial: Partial<PhaseRow>) {
setCfg((prev) => ({ ...prev, [key]: { ...prev[key], ...partial } }));
}
async function save() {
setSaving(true);
try {
const res = await fetch(`/api/medications/${medicationId}/phase-config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toApi(cfg)),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
toast.error(
(json as { error?: string }).error ??
t("medications.detail.phases.saveFailed"),
);
return;
}
await invalidateKeys(queryClient, [
...medicationDependentKeys,
queryKeys.medicationPhaseConfig(medicationId),
]);
toast.success(t("medications.phaseSaved"));
onClose();
} catch {
toast.error(t("medications.detail.phases.saveFailed"));
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
<div className="space-y-3" data-slot="phase-config-grid">
{PHASE_KEYS.map((key) => {
const inputId = `phase-config-${key}-value`;
return (
<div
key={key}
className="grid grid-cols-[1fr_auto_120px] items-end gap-2"
>
<Label htmlFor={inputId} className="text-sm font-medium">
{labels[key]}
</Label>
<Select
value={cfg[key].mode}
onValueChange={(v) => patch(key, { mode: v as PhaseMode })}
>
<SelectTrigger
className="w-[100px]"
aria-label={t("medications.detail.phases.modeLabel", {
phase: labels[key],
})}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MINUTES">
{t("medications.phaseMinutes")}
</SelectItem>
<SelectItem value="PERCENT">
{t("medications.phasePercent")}
</SelectItem>
</SelectContent>
</Select>
<Input
id={inputId}
type="number"
min={0}
max={1440}
value={cfg[key].value}
onChange={(e) =>
patch(key, { value: Number(e.target.value) || 0 })
}
className="text-right"
/>
</div>
);
})}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="ghost"
onClick={() => setCfg(DEFAULT_CONFIG)}
disabled={saving}
className="min-h-11 sm:min-h-9"
data-slot="phase-config-reset"
>
{t("medications.phaseResetDefaults")}
</Button>
<Button
onClick={() => void save()}
disabled={saving}
aria-busy={saving || undefined}
className="min-h-11 sm:min-h-9"
data-slot="phase-config-save"
>
{saving && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,208 @@
"use client";
/**
* v1.5.5 D-3 §9.7 — Settings section.
*
* Hosts the four sub-rows the v1.5.4 form retirement displaced:
* Externe Integration (API tokens), CSV-Import stub (one-line pointer
* to the intake-history header CTA, no button), Phasen (button →
* `<PhaseConfigSheet>`, mounts only on GLP-1 + course window set), and
* Grace minutes (primary-schedule-scoped).
*
* Per H-3-UX: the CSV-Import row is a one-line stub. The actual
* import affordance lives next to the table it changes (the
* intake-history header), so the page mounts the dialog exactly once
* via lifted state.
*
* Per H-4-UX: the Grace row's label flags it as primary-schedule
* scoped so the user knows a multi-schedule medication does not get a
* fan-out from this control.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface SettingsSectionProps {
medicationId: string;
medicationName: string;
treatmentClass?: string;
startsOn?: string | null;
endsOn?: string | null;
reminderGraceMinutes?: number | null;
}
export function SettingsSection({
medicationId,
medicationName,
treatmentClass,
startsOn,
endsOn,
reminderGraceMinutes,
}: SettingsSectionProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [phaseSheetOpen, setPhaseSheetOpen] = useState(false);
const [graceValue, setGraceValue] = useState(
typeof reminderGraceMinutes === "number" ? reminderGraceMinutes : 30,
);
const [graceBusy, setGraceBusy] = useState(false);
const isGlp1 = treatmentClass === "GLP1";
const hasCourseWindow = Boolean(startsOn) && Boolean(endsOn);
const showPhases = isGlp1 && hasCourseWindow;
async function saveGrace() {
if (graceBusy) return;
setGraceBusy(true);
try {
// Primary-schedule scope: the PUT body's schedules array is
// absent so the server preserves existing schedules; the route
// accepts top-level `reminderGraceMinutes` as a primary-schedule
// override on the v1.5.4 flat-form bridge.
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reminderGraceMinutes: graceValue }),
});
if (!res.ok) {
toast.error(t("medications.detail.settings.grace.failed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.settings.grace.saved"));
} catch {
toast.error(t("medications.detail.settings.grace.failed"));
} finally {
setGraceBusy(false);
}
}
return (
<MedicationDetailSection
titleId="medication-detail-settings-heading"
title={t("medications.detail.settings.title")}
dataSlot="medication-detail-settings-section"
>
<div className="space-y-4" data-slot="medication-detail-settings-body">
{/* Externe Integration — API tokens */}
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
<Separator />
{/* CSV-Import stub — H-3-UX */}
<div
className="space-y-1"
data-slot="medication-detail-csv-import-row"
>
<p className="text-foreground text-sm font-medium">
{t("medications.detail.settings.csvImport.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.settings.csvImport.stub")}
</p>
</div>
{/* Phasen — GLP-1 + course-window gated */}
{isGlp1 && (
<>
<Separator />
<div
className="space-y-2"
data-slot="medication-detail-phase-management-row"
>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.phaseConfig")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.phaseConfigDescription")}
</p>
</div>
{showPhases ? (
<Button
variant="outline"
size="sm"
onClick={() => setPhaseSheetOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="medication-detail-phase-management-button"
>
{t("medications.detail.settings.phases.openButton")}
</Button>
) : (
<p
className="text-muted-foreground text-xs italic"
data-slot="medication-detail-phase-management-empty"
>
{t("medications.detail.settings.phases.requiresCourseWindow")}
</p>
)}
</div>
</>
)}
<Separator />
{/* Grace minutes — primary-schedule scoped */}
<div
className="space-y-2"
data-slot="medication-detail-grace-row"
>
<Label
htmlFor="medication-detail-grace-input"
className="text-foreground text-sm font-medium"
>
{t("medications.detail.settings.grace.label")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.detail.settings.grace.primaryScheduleNote")}
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
id="medication-detail-grace-input"
type="number"
min={0}
max={720}
value={graceValue}
onChange={(e) => setGraceValue(Number(e.target.value) || 0)}
className="w-24"
/>
<span className="text-muted-foreground text-xs">
{t("medications.detail.settings.grace.unit")}
</span>
<Button
size="sm"
onClick={() => void saveGrace()}
disabled={graceBusy}
aria-busy={graceBusy || undefined}
className="min-h-11 sm:min-h-9"
>
{t("common.save")}
</Button>
</div>
</div>
</div>
{showPhases && (
<PhaseConfigSheet
medicationId={medicationId}
open={phaseSheetOpen}
onOpenChange={setPhaseSheetOpen}
/>
)}
</MedicationDetailSection>
);
}
@@ -0,0 +1,266 @@
"use client";
/**
* v1.5.5 D-3 §9.2 — Today's-dose card.
*
* Two buttons: `[Genommen]` posts a confirmed-taken intake;
* `[Übersprungen]` posts a skipped one. The wire shape is the one the
* intake POST already understands:
*
* `{ scheduledFor: <today-iso>, takenAt: <now-iso>, skipped: boolean }`
*
* (The C-E2-1 fix dropped the "Verschoben / DEFERRED" third branch that
* an earlier draft proposed but the schema never carried.)
*
* Optimistic update: the moment the request fires, the card flips into
* read-only `Heute genommen um HH:MM` (or its skipped twin). On error
* the read-only state rolls back and an inline destructive note
* surfaces. The polite live region (the parent's `<Toaster>`) carries
* the toast announcement; this card additionally persists state
* visibly in the same paint as the toast (E-4 C-1).
*
* Paused state: when the medication is inactive both buttons render
* `aria-disabled` + visually muted with a single helper line.
*
* Cache: invalidates the bundle via `medicationDependentKeys` so every
* downstream tile (inline compliance chart, dashboard rollup tile)
* picks the new event up in one tick.
*/
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, SkipForward } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
export interface TodaysDoseCardProps {
medicationId: string;
active: boolean;
oneShot: boolean;
/**
* Server-side "due today" decision. The detail page derives it from
* the medication's cadence + the cached intake list and passes it
* in so the card stays presentation-only.
*/
scheduledForToday: boolean;
/**
* If the user already logged today's intake, the time the row
* captured. The card uses it to render the read-only state
* immediately on mount (rather than waiting for the optimistic
* branch to populate it).
*/
alreadyTakenAt?: string | null;
alreadySkipped?: boolean;
}
type Optimistic =
| { kind: "idle" }
| { kind: "submitting"; skipped: boolean }
| { kind: "done"; at: string; skipped: boolean }
| { kind: "error"; message: string };
export function TodaysDoseCard({
medicationId,
active,
oneShot,
scheduledForToday,
alreadyTakenAt,
alreadySkipped,
}: TodaysDoseCardProps) {
const { t } = useTranslations();
const formatters = useFormatters();
const queryClient = useQueryClient();
const initialState: Optimistic = alreadyTakenAt
? { kind: "done", at: alreadyTakenAt, skipped: false }
: alreadySkipped
? { kind: "done", at: new Date().toISOString(), skipped: true }
: { kind: "idle" };
const [state, setState] = useState<Optimistic>(initialState);
const liveLabel = useMemo(() => {
if (state.kind === "done") {
const timeLabel = formatters.dateTime(state.at);
if (state.skipped) {
return t("medications.detail.today.recordedSkipped", {
time: timeLabel,
});
}
return oneShot
? t("medications.detail.today.recordedOneShot", { time: timeLabel })
: t("medications.detail.today.recordedTaken", { time: timeLabel });
}
return null;
}, [formatters, oneShot, state, t]);
async function submit(skipped: boolean) {
if (state.kind === "submitting") return;
setState({ kind: "submitting", skipped });
const now = new Date();
const body = {
scheduledFor: now.toISOString(),
takenAt: skipped ? null : now.toISOString(),
skipped,
};
try {
const res = await fetch(`/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
const message = json.error ?? t("medications.detail.today.error");
setState({ kind: "error", message });
return;
}
setState({ kind: "done", at: now.toISOString(), skipped });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(
skipped
? t("medications.detail.today.toastSkipped")
: t("medications.detail.today.toastTaken"),
);
} catch {
setState({
kind: "error",
message: t("medications.detail.today.error"),
});
}
}
// Paused / inactive state: both buttons sit muted with one helper.
if (!active) {
return (
<Card
className="p-5 sm:p-6 space-y-4"
data-slot="todays-dose-card"
data-state="paused"
>
<p
className="text-muted-foreground text-sm"
data-slot="todays-dose-paused"
>
{t("medications.detail.today.pausedHint")}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
aria-disabled="true"
tabIndex={-1}
className="min-h-11 sm:min-h-9 opacity-60"
>
<Check aria-hidden="true" className="mr-2 h-4 w-4" />
{t("medications.detail.today.taken")}
</Button>
<Button
variant="outline"
aria-disabled="true"
tabIndex={-1}
className="min-h-11 sm:min-h-9 opacity-60"
>
<SkipForward aria-hidden="true" className="mr-2 h-4 w-4" />
{t("medications.detail.today.skipped")}
</Button>
</div>
</Card>
);
}
// Read-only post-log state — switches in the same paint as the toast
// so the user sees the dose persisted without a refresh round-trip.
if (state.kind === "done") {
return (
<Card
className="p-5 sm:p-6 space-y-2"
data-slot="todays-dose-card"
data-state="done"
role="status"
aria-live="polite"
>
<p className="text-foreground text-sm font-medium">{liveLabel}</p>
</Card>
);
}
// Empty: nothing scheduled for today (recurring path only).
if (!scheduledForToday && !oneShot) {
return (
<Card
className="p-5 sm:p-6"
data-slot="todays-dose-card"
data-state="empty"
>
<p className="text-muted-foreground text-sm">
{t("medications.detail.today.noneScheduled")}
</p>
</Card>
);
}
const submitting = state.kind === "submitting";
return (
<Card
className="p-5 sm:p-6 space-y-4"
data-slot="todays-dose-card"
data-state="idle"
>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label={t("medications.detail.today.groupLabel")}
>
<Button
onClick={() => void submit(false)}
disabled={submitting}
aria-busy={submitting && !state.skipped ? true : undefined}
className="min-h-11 sm:min-h-9"
data-slot="todays-dose-taken"
>
{submitting && !state.skipped ? (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Check aria-hidden="true" className="mr-2 h-4 w-4" />
)}
{t("medications.detail.today.taken")}
</Button>
<Button
variant="outline"
onClick={() => void submit(true)}
disabled={submitting}
aria-busy={submitting && state.skipped ? true : undefined}
className="min-h-11 sm:min-h-9"
data-slot="todays-dose-skipped"
>
{submitting && state.skipped ? (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
) : (
<SkipForward aria-hidden="true" className="mr-2 h-4 w-4" />
)}
{t("medications.detail.today.skipped")}
</Button>
</div>
{state.kind === "error" && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="todays-dose-error"
>
{state.message}
</p>
)}
</Card>
);
}
@@ -735,6 +735,8 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => {
category: "OTHER",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
oneShot: false,
schedules: [
{
@@ -760,6 +762,8 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => {
category: "OTHER",
treatmentClass: "GENERIC",
notificationsEnabled: true,
startsOn: new Date(Date.UTC(2026, 4, 28)),
endsOn: null,
oneShot: true,
schedules: [
{