mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
7f95a2a44f
commit
4ae8801c56
17 files changed
+2953
-4
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user