mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #337 from MBombeck/release/v1.18.2
v1.18.2 — preventive care as medication-style cards + condition-journal clarity
This commit is contained in:
20 files changed
+1377
-249
No files matched your search
@@ -2,6 +2,19 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.18.2] — 2026-06-17 — preventive care, the same as your medications
|
||||
|
||||
This release brings preventive care into line with how medications already work, and clears up the condition journal. Each preventive-care item is now its own card in the same style as a medication, and marking one done behaves the way you'd expect: a check-up you plan for yourself is a simple "done", while a reminder tied to a measurement opens the actual entry form — completing a blood-pressure reminder records the reading rather than just ticking a box. Items take a first-due date and a custom interval, the page gained a settings control like the labs and condition pages, and preventive care can now sit on the dashboard as its own tile. The condition journal was reorganised so it reads at a glance. No breaking changes; no migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Preventive care looks and works like your medications.** Every reminder is its own card with one clear action. A self-planned check-up is marked done in a tap; a reminder linked to a measurement opens the matching entry form, so finishing it captures the value and clears the reminder in one step. Reminders take a first-due date and a custom interval, can be edited from the card, and the page carries a settings control to manage them. Preventive care can be placed on the dashboard like any other area.
|
||||
- **The condition journal reads at a glance.** Conditions are grouped into active and resolved, flares sit under the condition they belong to, each row carries a single clear action with the rest behind a menu, and a condition's daily timeline now lives on its own page. The retrospective summary sits up top where you'll see it.
|
||||
|
||||
### Operator note
|
||||
|
||||
- UI-only release — no schema change and no migration. Preventive care ships on the dashboard switched off by default; enable it from the dashboard layout settings.
|
||||
|
||||
## [1.18.1] — 2026-06-16 — labs that hold a catalog, a condition journal, and reminders that only nudge when you forget
|
||||
|
||||
A large release that turns two thin features into proper ones and adds two more, all in the existing visual language. Lab results gain a biomarker catalog: define a marker once with its unit and reference range, then log a value by picking it — with a full dashboard-style chart, edit and undo. A new condition journal records an illness from onset to recovery and, once it has enough of your own history, reflects back how it announced itself and how long recovery took — retrospective only, never a diagnosis. Preventive-care reminders now fire only when a measurement is actually overdue and clear themselves the moment a reading arrives from any source, and the coach can suggest an evidence-based measurement cadence you accept with one tap — both feeding one shared reminder engine. The settings menu, the dashboard header, the recovery page and the coach view were tightened so every module reads as one app. Medications can now be switched off like any other module; the core vitals — weight, blood pressure, pulse — stay always on. No breaking changes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.18.1
|
||||
version: 1.18.2
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+43
-3
@@ -6405,7 +6405,9 @@
|
||||
"overdueByDays": "Überfällig seit {days} Tagen",
|
||||
"cadence": {
|
||||
"everyNDays": "Alle {days} Tage",
|
||||
"custom": "Eigener Zeitplan"
|
||||
"custom": "Eigener Zeitplan",
|
||||
"customInterval": "Individuell (alle N Tage)",
|
||||
"rruleOption": "Eigene Regel (RRULE)"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "Bei: {location}"
|
||||
@@ -6448,7 +6450,11 @@
|
||||
"locationPlaceholder": "z. B. Hausarzt, Labor",
|
||||
"save": "Speichern",
|
||||
"createTitle": "Neue Erinnerung",
|
||||
"editTitle": "Erinnerung bearbeiten"
|
||||
"editTitle": "Erinnerung bearbeiten",
|
||||
"customInterval": "Wiederholen alle (Tage)",
|
||||
"rrule": "Wiederholungsregel (RRULE)",
|
||||
"rruleHint": "RFC-5545, z. B. FREQ=YEARLY für eine jährliche Untersuchung.",
|
||||
"firstDue": "Erste Fälligkeit (optional)"
|
||||
},
|
||||
"pushTitle": "Vorsorge-Erinnerung",
|
||||
"pushBody": "Fällig: {label}",
|
||||
@@ -6456,7 +6462,23 @@
|
||||
"edit": "Bearbeiten",
|
||||
"originCoach": "Coach",
|
||||
"enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren",
|
||||
"until": "Bis {date}"
|
||||
"until": "Bis {date}",
|
||||
"selfPlanned": "Selbstgeplant",
|
||||
"captureValue": "Wert erfassen",
|
||||
"capture": {
|
||||
"title": "Messwert erfassen",
|
||||
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Anpassen",
|
||||
"title": "Erinnerungen anpassen",
|
||||
"description": "Einzelne Erinnerungen aktivieren oder deaktivieren.",
|
||||
"empty": "Noch keine Erinnerungen zum Anpassen."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Keine anstehenden Vorsorge-Erinnerungen.",
|
||||
"openLink": "Vorsorge öffnen"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laborwerte",
|
||||
@@ -6791,6 +6813,24 @@
|
||||
"byMonth": "Wann du Episoden erfasst",
|
||||
"none": "In diesem Zeitraum keine Episoden erfasst."
|
||||
},
|
||||
"section": {
|
||||
"active": "Aktiv",
|
||||
"resolved": "Abgeschlossen"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 Schub",
|
||||
"countOther": "{count} Schübe"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Tagesverlauf",
|
||||
"error": "Der heutige Eintrag konnte nicht geladen werden.",
|
||||
"emptyToday": "Heute noch nichts erfasst.",
|
||||
"today": "Heute · {date}",
|
||||
"severity": "Stärke {severity}",
|
||||
"noSymptoms": "Keine Symptome erfasst.",
|
||||
"impact": "Befinden: {impact}",
|
||||
"fever": "Temperatur: {value} °C"
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
"detail": {
|
||||
|
||||
+43
-3
@@ -6405,7 +6405,9 @@
|
||||
"overdueByDays": "Overdue by {days} days",
|
||||
"cadence": {
|
||||
"everyNDays": "Every {days} days",
|
||||
"custom": "Custom schedule"
|
||||
"custom": "Custom schedule",
|
||||
"customInterval": "Custom (every N days)",
|
||||
"rruleOption": "Custom rule (RRULE)"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "At: {location}"
|
||||
@@ -6448,7 +6450,11 @@
|
||||
"locationPlaceholder": "e.g. GP, Lab",
|
||||
"save": "Save",
|
||||
"createTitle": "New reminder",
|
||||
"editTitle": "Edit reminder"
|
||||
"editTitle": "Edit reminder",
|
||||
"customInterval": "Repeat every (days)",
|
||||
"rrule": "Recurrence rule (RRULE)",
|
||||
"rruleHint": "RFC-5545, e.g. FREQ=YEARLY for an annual check.",
|
||||
"firstDue": "First due (optional)"
|
||||
},
|
||||
"pushTitle": "Preventive-care reminder",
|
||||
"pushBody": "Time for: {label}",
|
||||
@@ -6456,7 +6462,23 @@
|
||||
"edit": "Edit",
|
||||
"originCoach": "Coach",
|
||||
"enabledToggleAria": "Enable or disable this reminder",
|
||||
"until": "Until {date}"
|
||||
"until": "Until {date}",
|
||||
"selfPlanned": "Self-planned",
|
||||
"captureValue": "Log value",
|
||||
"capture": {
|
||||
"title": "Log measurement",
|
||||
"description": "Enter the reading; the reminder is marked done."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Customize",
|
||||
"title": "Customize reminders",
|
||||
"description": "Enable or disable individual reminders.",
|
||||
"empty": "No reminders to customize yet."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "No upcoming preventive-care reminders.",
|
||||
"openLink": "Open preventive care"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Lab results",
|
||||
@@ -6791,6 +6813,24 @@
|
||||
"byMonth": "When you tend to log episodes",
|
||||
"none": "No episodes logged in this period."
|
||||
},
|
||||
"section": {
|
||||
"active": "Active",
|
||||
"resolved": "Resolved"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 flare",
|
||||
"countOther": "{count} flares"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Day timeline",
|
||||
"error": "Could not load today's entry.",
|
||||
"emptyToday": "Nothing logged today yet.",
|
||||
"today": "Today · {date}",
|
||||
"severity": "Severity {severity}",
|
||||
"noSymptoms": "No symptoms logged.",
|
||||
"impact": "How you functioned: {impact}",
|
||||
"fever": "Temperature: {value} °C"
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
"detail": {
|
||||
|
||||
+43
-3
@@ -6405,7 +6405,9 @@
|
||||
"overdueByDays": "Vencido hace {days} días",
|
||||
"cadence": {
|
||||
"everyNDays": "Cada {days} días",
|
||||
"custom": "Programación personalizada"
|
||||
"custom": "Programación personalizada",
|
||||
"customInterval": "Personalizado (cada N días)",
|
||||
"rruleOption": "Regla propia (RRULE)"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "En: {location}"
|
||||
@@ -6448,7 +6450,11 @@
|
||||
"locationPlaceholder": "p. ej. Médico, Laboratorio",
|
||||
"save": "Guardar",
|
||||
"createTitle": "Nuevo recordatorio",
|
||||
"editTitle": "Editar recordatorio"
|
||||
"editTitle": "Editar recordatorio",
|
||||
"customInterval": "Repetir cada (días)",
|
||||
"rrule": "Regla de recurrencia (RRULE)",
|
||||
"rruleHint": "RFC-5545, p. ej. FREQ=YEARLY para una revisión anual.",
|
||||
"firstDue": "Primer vencimiento (opcional)"
|
||||
},
|
||||
"pushTitle": "Recordatorio de prevención",
|
||||
"pushBody": "Toca: {label}",
|
||||
@@ -6456,7 +6462,23 @@
|
||||
"edit": "Editar",
|
||||
"originCoach": "Coach",
|
||||
"enabledToggleAria": "Activar o desactivar este recordatorio",
|
||||
"until": "Hasta {date}"
|
||||
"until": "Hasta {date}",
|
||||
"selfPlanned": "Autoplanificado",
|
||||
"captureValue": "Registrar valor",
|
||||
"capture": {
|
||||
"title": "Registrar medición",
|
||||
"description": "Introduce el valor; el recordatorio se marca como hecho."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Personalizar",
|
||||
"title": "Personalizar recordatorios",
|
||||
"description": "Activa o desactiva recordatorios individuales.",
|
||||
"empty": "Aún no hay recordatorios para personalizar."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "No hay recordatorios de prevención próximos.",
|
||||
"openLink": "Abrir prevención"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Resultados de análisis",
|
||||
@@ -6791,6 +6813,24 @@
|
||||
"byMonth": "Cuándo sueles registrar episodios",
|
||||
"none": "Sin episodios registrados en este período."
|
||||
},
|
||||
"section": {
|
||||
"active": "Activos",
|
||||
"resolved": "Resueltos"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 brote",
|
||||
"countOther": "{count} brotes"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronología del día",
|
||||
"error": "No se pudo cargar el registro de hoy.",
|
||||
"emptyToday": "Aún no hay nada registrado hoy.",
|
||||
"today": "Hoy · {date}",
|
||||
"severity": "Intensidad {severity}",
|
||||
"noSymptoms": "Sin síntomas registrados.",
|
||||
"impact": "Cómo funcionaste: {impact}",
|
||||
"fever": "Temperatura: {value} °C"
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
"detail": {
|
||||
|
||||
+43
-3
@@ -6405,7 +6405,9 @@
|
||||
"overdueByDays": "En retard de {days} jours",
|
||||
"cadence": {
|
||||
"everyNDays": "Tous les {days} jours",
|
||||
"custom": "Planning personnalisé"
|
||||
"custom": "Planning personnalisé",
|
||||
"customInterval": "Personnalisé (tous les N jours)",
|
||||
"rruleOption": "Règle personnalisée (RRULE)"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "Chez : {location}"
|
||||
@@ -6448,7 +6450,11 @@
|
||||
"locationPlaceholder": "ex. Médecin, Laboratoire",
|
||||
"save": "Enregistrer",
|
||||
"createTitle": "Nouveau rappel",
|
||||
"editTitle": "Modifier le rappel"
|
||||
"editTitle": "Modifier le rappel",
|
||||
"customInterval": "Répéter tous les (jours)",
|
||||
"rrule": "Règle de récurrence (RRULE)",
|
||||
"rruleHint": "RFC-5545, p. ex. FREQ=YEARLY pour un contrôle annuel.",
|
||||
"firstDue": "Première échéance (facultatif)"
|
||||
},
|
||||
"pushTitle": "Rappel de prévention",
|
||||
"pushBody": "C'est l'heure : {label}",
|
||||
@@ -6456,7 +6462,23 @@
|
||||
"edit": "Modifier",
|
||||
"originCoach": "Coach",
|
||||
"enabledToggleAria": "Activer ou désactiver ce rappel",
|
||||
"until": "Jusqu'au {date}"
|
||||
"until": "Jusqu'au {date}",
|
||||
"selfPlanned": "Auto-planifié",
|
||||
"captureValue": "Enregistrer la valeur",
|
||||
"capture": {
|
||||
"title": "Enregistrer une mesure",
|
||||
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Personnaliser",
|
||||
"title": "Personnaliser les rappels",
|
||||
"description": "Activer ou désactiver des rappels individuels.",
|
||||
"empty": "Aucun rappel à personnaliser pour l'instant."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Aucun rappel de prévention à venir.",
|
||||
"openLink": "Ouvrir la prévention"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Résultats d'analyses",
|
||||
@@ -6791,6 +6813,24 @@
|
||||
"byMonth": "Quand tu enregistres des épisodes",
|
||||
"none": "Aucun épisode enregistré sur cette période."
|
||||
},
|
||||
"section": {
|
||||
"active": "Actifs",
|
||||
"resolved": "Résolus"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 poussée",
|
||||
"countOther": "{count} poussées"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronologie du jour",
|
||||
"error": "Impossible de charger l'entrée du jour.",
|
||||
"emptyToday": "Rien d'enregistré aujourd'hui pour l'instant.",
|
||||
"today": "Aujourd'hui · {date}",
|
||||
"severity": "Intensité {severity}",
|
||||
"noSymptoms": "Aucun symptôme enregistré.",
|
||||
"impact": "Votre forme : {impact}",
|
||||
"fever": "Température : {value} °C"
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
"detail": {
|
||||
|
||||
+43
-3
@@ -6405,7 +6405,9 @@
|
||||
"overdueByDays": "In ritardo di {days} giorni",
|
||||
"cadence": {
|
||||
"everyNDays": "Ogni {days} giorni",
|
||||
"custom": "Pianificazione personalizzata"
|
||||
"custom": "Pianificazione personalizzata",
|
||||
"customInterval": "Personalizzato (ogni N giorni)",
|
||||
"rruleOption": "Regola personalizzata (RRULE)"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "Presso: {location}"
|
||||
@@ -6448,7 +6450,11 @@
|
||||
"locationPlaceholder": "es. Medico, Laboratorio",
|
||||
"save": "Salva",
|
||||
"createTitle": "Nuovo promemoria",
|
||||
"editTitle": "Modifica promemoria"
|
||||
"editTitle": "Modifica promemoria",
|
||||
"customInterval": "Ripeti ogni (giorni)",
|
||||
"rrule": "Regola di ricorrenza (RRULE)",
|
||||
"rruleHint": "RFC-5545, es. FREQ=YEARLY per un controllo annuale.",
|
||||
"firstDue": "Prima scadenza (facoltativo)"
|
||||
},
|
||||
"pushTitle": "Promemoria di prevenzione",
|
||||
"pushBody": "È ora di: {label}",
|
||||
@@ -6456,7 +6462,23 @@
|
||||
"edit": "Modifica",
|
||||
"originCoach": "Coach",
|
||||
"enabledToggleAria": "Attiva o disattiva questo promemoria",
|
||||
"until": "Fino al {date}"
|
||||
"until": "Fino al {date}",
|
||||
"selfPlanned": "Autopianificato",
|
||||
"captureValue": "Registra valore",
|
||||
"capture": {
|
||||
"title": "Registra misurazione",
|
||||
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Personalizza",
|
||||
"title": "Personalizza promemoria",
|
||||
"description": "Attiva o disattiva singoli promemoria.",
|
||||
"empty": "Nessun promemoria da personalizzare."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Nessun promemoria di prevenzione in arrivo.",
|
||||
"openLink": "Apri prevenzione"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Risultati degli esami",
|
||||
@@ -6791,6 +6813,24 @@
|
||||
"byMonth": "Quando tendi a registrare episodi",
|
||||
"none": "Nessun episodio registrato in questo periodo."
|
||||
},
|
||||
"section": {
|
||||
"active": "Attivi",
|
||||
"resolved": "Risolti"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 riacutizzazione",
|
||||
"countOther": "{count} riacutizzazioni"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronologia del giorno",
|
||||
"error": "Impossibile caricare il diario di oggi.",
|
||||
"emptyToday": "Oggi non hai ancora registrato nulla.",
|
||||
"today": "Oggi · {date}",
|
||||
"severity": "Intensità {severity}",
|
||||
"noSymptoms": "Nessun sintomo registrato.",
|
||||
"impact": "Come ti sei sentito: {impact}",
|
||||
"fever": "Temperatura: {value} °C"
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
"detail": {
|
||||
|
||||
+43
-3
@@ -6405,7 +6405,9 @@
|
||||
"overdueByDays": "Zaległe od {days} dni",
|
||||
"cadence": {
|
||||
"everyNDays": "Co {days} dni",
|
||||
"custom": "Własny harmonogram"
|
||||
"custom": "Własny harmonogram",
|
||||
"customInterval": "Niestandardowe (co N dni)",
|
||||
"rruleOption": "Własna reguła (RRULE)"
|
||||
},
|
||||
"location": {
|
||||
"prefix": "U: {location}"
|
||||
@@ -6448,7 +6450,11 @@
|
||||
"locationPlaceholder": "np. Lekarz, Laboratorium",
|
||||
"save": "Zapisz",
|
||||
"createTitle": "Nowe przypomnienie",
|
||||
"editTitle": "Edytuj przypomnienie"
|
||||
"editTitle": "Edytuj przypomnienie",
|
||||
"customInterval": "Powtarzaj co (dni)",
|
||||
"rrule": "Reguła powtarzania (RRULE)",
|
||||
"rruleHint": "RFC-5545, np. FREQ=YEARLY dla corocznego badania.",
|
||||
"firstDue": "Pierwszy termin (opcjonalnie)"
|
||||
},
|
||||
"pushTitle": "Przypomnienie profilaktyczne",
|
||||
"pushBody": "Czas na: {label}",
|
||||
@@ -6456,7 +6462,23 @@
|
||||
"edit": "Edytuj",
|
||||
"originCoach": "Coach",
|
||||
"enabledToggleAria": "Włącz lub wyłącz to przypomnienie",
|
||||
"until": "Do {date}"
|
||||
"until": "Do {date}",
|
||||
"selfPlanned": "Zaplanowane samodzielnie",
|
||||
"captureValue": "Zapisz wartość",
|
||||
"capture": {
|
||||
"title": "Zapisz pomiar",
|
||||
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Dostosuj",
|
||||
"title": "Dostosuj przypomnienia",
|
||||
"description": "Włącz lub wyłącz poszczególne przypomnienia.",
|
||||
"empty": "Brak przypomnień do dostosowania."
|
||||
},
|
||||
"dashboard": {
|
||||
"empty": "Brak nadchodzących przypomnień profilaktycznych.",
|
||||
"openLink": "Otwórz profilaktykę"
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
@@ -6791,6 +6813,24 @@
|
||||
"byMonth": "Kiedy zwykle zapisujesz epizody",
|
||||
"none": "Brak epizodów zapisanych w tym okresie."
|
||||
},
|
||||
"section": {
|
||||
"active": "Aktywne",
|
||||
"resolved": "Zakończone"
|
||||
},
|
||||
"flares": {
|
||||
"countOne": "1 zaostrzenie",
|
||||
"countOther": "Zaostrzenia: {count}"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Oś dnia",
|
||||
"error": "Nie udało się wczytać dzisiejszego wpisu.",
|
||||
"emptyToday": "Dziś nic jeszcze nie zapisano.",
|
||||
"today": "Dziś · {date}",
|
||||
"severity": "Nasilenie {severity}",
|
||||
"noSymptoms": "Nie zapisano objawów.",
|
||||
"impact": "Jak funkcjonowałeś: {impact}",
|
||||
"fever": "Temperatura: {value} °C"
|
||||
},
|
||||
"loadError": "Could not load this episode.",
|
||||
"backToList": "Back to journal",
|
||||
"detail": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.18.1",
|
||||
"version": "1.18.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -55,6 +55,7 @@ import { GettingStartedChecklist } from "@/components/onboarding/getting-started
|
||||
import { TourLauncher } from "@/components/onboarding/tour-launcher";
|
||||
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
|
||||
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
|
||||
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
|
||||
|
||||
// v1.4.40 W-RSC — module-scope so the option object is stable across
|
||||
// renders (audit-M2). Pre-fix the same literal was declared inside the
|
||||
@@ -201,6 +202,8 @@ const CHART_CAPABLE_WIDGET_IDS = new Set<string>([
|
||||
"sleep",
|
||||
"steps",
|
||||
"medications",
|
||||
// v1.18.2 — Vorsorge preventive-care summary card (chart-row only).
|
||||
"vorsorge",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -540,6 +543,9 @@ export default function DashboardPage() {
|
||||
const showSleepChart = isChartVisible("sleep") && hasSleep;
|
||||
const showStepsChart = isChartVisible("steps") && hasSteps;
|
||||
const showMedicationsCard = isChartVisible("medications");
|
||||
// v1.18.2 — Vorsorge summary card on the chart row (opt-in). Always-on
|
||||
// data surface, so it gates on the layout toggle alone.
|
||||
const showVorsorgeCard = isChartVisible("vorsorge");
|
||||
// `layoutData` is undefined until the real layout (snapshot or legacy
|
||||
// widgets) resolves; `resolveDashboardLayout(undefined)` falls back to
|
||||
// DEFAULT_DASHBOARD_LAYOUT, where `achievements` is visible by default.
|
||||
@@ -1404,6 +1410,17 @@ export default function DashboardPage() {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showVorsorgeCard) {
|
||||
// v1.18.2 — Vorsorge preventive-care summary card. Slotted via
|
||||
// the layout `order` like medications; self-fetches its reminder
|
||||
// list and self-skeletons, so it stays out of the reveal gate
|
||||
// (no chart-shaped data-ready signal).
|
||||
charts.push({
|
||||
id: "vorsorge",
|
||||
order: widgetOrder("vorsorge"),
|
||||
node: <VorsorgeDashboardCard key="vorsorge" />,
|
||||
});
|
||||
}
|
||||
if (showAchievementsCard) {
|
||||
// v1.4.15 phase-B4 — slotted at the user's chosen position via
|
||||
// the layout `order`. Default order from
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.2 — the per-day timeline anchor, hosted exclusively on the episode
|
||||
* detail surface. It surfaces today's logged day (symptoms with their 0–3
|
||||
* severity, functional impact, fever) so the detail page owns the day-log
|
||||
* content the list rows no longer carry. Retrospective only — a record of
|
||||
* what was logged, never a forecast. Neutral palette throughout.
|
||||
*
|
||||
* The read uses the single-day `useIllnessDayLog` hook (the only day-log read
|
||||
* the API exposes); when nothing is logged for the day it renders a calm
|
||||
* prompt to log it.
|
||||
*/
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
import { useIllnessDayLog } from "./use-illness";
|
||||
|
||||
function symptomLabelKey(key: string): string | null {
|
||||
return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null;
|
||||
}
|
||||
|
||||
export function IllnessDayTimeline({
|
||||
episodeId,
|
||||
date,
|
||||
onLogDay,
|
||||
}: {
|
||||
episodeId: string;
|
||||
date: string;
|
||||
onLogDay: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data, isLoading, isError } = useIllnessDayLog(episodeId, date);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("illness.timeline.title")}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.error")}
|
||||
</p>
|
||||
) : !data ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("illness.timeline.emptyToday")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onLogDay}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5 text-sm">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{t("illness.timeline.today", {
|
||||
date: fmt.dateShort(`${data.date}T12:00:00`),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{data.symptoms.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.symptoms.map((s) => {
|
||||
const labelKey = symptomLabelKey(s.key);
|
||||
if (!labelKey) return null;
|
||||
return (
|
||||
<Badge key={s.key} variant="secondary">
|
||||
{t(labelKey)}
|
||||
{typeof s.severity === "number"
|
||||
? ` · ${t("illness.timeline.severity", {
|
||||
severity: s.severity,
|
||||
})}`
|
||||
: ""}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t("illness.timeline.noSymptoms")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.functionalImpact !== null ? (
|
||||
<p className="text-foreground">
|
||||
{t("illness.timeline.impact", {
|
||||
impact: t(`illness.impact.${data.functionalImpact}`),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.feverC !== null ? (
|
||||
<p className="text-foreground">
|
||||
{t("illness.timeline.fever", {
|
||||
value: fmt.number(data.feverC, 1),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.note ? (
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{data.note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { IllnessCorrelationCard } from "./illness-correlation-card";
|
||||
import { IllnessDayTimeline } from "./illness-day-timeline";
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
import { EpisodeMenu } from "./episode-menu";
|
||||
@@ -114,6 +115,17 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* The per-day timeline lives only on the detail surface — the list
|
||||
rows stay clean summaries, so navigating here reveals genuinely new
|
||||
content (today's logged symptoms / impact / fever). */}
|
||||
{episode ? (
|
||||
<IllnessDayTimeline
|
||||
episodeId={episode.id}
|
||||
date={today}
|
||||
onLogDay={() => setLogOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the illness / condition-journal surface.
|
||||
* v1.18.2 — the illness / condition-journal surface.
|
||||
*
|
||||
* A calm, retrospective journal: the episode history (newest first) with a
|
||||
* neutral status Badge (active / recovered — never an alarming colour, the
|
||||
* med-card rule generalised), a shared overflow kebab (Edit / Mark recovered /
|
||||
* AlertDialog-gated Delete), a "log a day" entry, and the cross-episode
|
||||
* retrospective summary. Each row navigates to the episode-detail surface
|
||||
* (`/illness/[id]`) for the correlation card. Retrospective-only copy — a
|
||||
* A calm, retrospective journal. The episode history reads as a grid of
|
||||
* medication-styled cards: a header (label link + neutral type / status
|
||||
* badges + an overflow kebab) and a single bottom-pinned "Log a day"
|
||||
* primary action — one clear action per card, the most-common one promoted.
|
||||
* The list is split into "Active" and "Resolved" groups (active first), and
|
||||
* flares nest under their parent condition so the parent relationship the
|
||||
* form captures is visible. The cross-episode retrospective summary sits
|
||||
* directly under the header so a long list never buries it. Each card
|
||||
* navigates to the episode-detail surface (`/illness/[id]`) where the
|
||||
* per-day timeline and correlation card live. Retrospective-only copy — a
|
||||
* journal, not a medical device, and it does not diagnose.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Plus, Stethoscope } from "lucide-react";
|
||||
import { Plus, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
@@ -38,29 +49,55 @@ function todayLocal(): string {
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function EpisodeRow({
|
||||
episode,
|
||||
onLogDay,
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
}: {
|
||||
interface EpisodeCardProps {
|
||||
episode: IllnessEpisodeDTO;
|
||||
/** Flares that hang off this episode (rendered nested), already filtered. */
|
||||
flares?: IllnessEpisodeDTO[];
|
||||
onLogDay: (id: string) => void;
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
}) {
|
||||
}
|
||||
|
||||
function EpisodeCard({
|
||||
episode,
|
||||
flares,
|
||||
onLogDay,
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const active = episode.resolvedAt === null;
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
const flareCount = flares?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card className="flex items-center gap-3 p-4">
|
||||
<Link href={`/illness/${episode.id}`} className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{episode.label}</span>
|
||||
<Card className="h-full gap-3 md:gap-3">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="min-w-0">
|
||||
<Link
|
||||
href={`/illness/${episode.id}`}
|
||||
className="hover:underline focus-visible:underline"
|
||||
>
|
||||
<span className="block truncate">{episode.label}</span>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex h-full flex-col space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="secondary">{t(`illness.type.${episode.type}`)}</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
@@ -70,41 +107,100 @@ function EpisodeRow({
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("illness.onsetOn", {
|
||||
date: fmt.dateShort(new Date(episode.onsetAt)),
|
||||
})}
|
||||
{episode.resolvedAt
|
||||
? ` · ${t("illness.recoveredOn", {
|
||||
date: fmt.dateShort(new Date(episode.resolvedAt)),
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => onEdit(episode)}
|
||||
onResolve={
|
||||
active && !isChronic ? () => onResolve(episode.id) : undefined
|
||||
}
|
||||
resolving={resolving}
|
||||
/>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground hidden h-4 w-4 sm:block"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{flareCount > 0 ? (
|
||||
<div className="space-y-1 border-l-2 pl-3">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{flareCount === 1
|
||||
? t("illness.flares.countOne")
|
||||
: t("illness.flares.countOther", { count: flareCount })}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{flares!.map((flare) => (
|
||||
<li key={flare.id}>
|
||||
<Link
|
||||
href={`/illness/${flare.id}`}
|
||||
className="text-foreground/80 hover:text-foreground flex items-center gap-2 text-xs hover:underline"
|
||||
>
|
||||
<span className="truncate">{flare.label}</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{fmt.dateShort(new Date(flare.onsetAt))}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Bottom-pinned single primary action — the daily-use "log a day"
|
||||
flow, promoted from a busy inline cluster. The card navigates to
|
||||
the detail surface on its title link; this is the one explicit
|
||||
button. */}
|
||||
<div className="mt-auto pt-0">
|
||||
<Button
|
||||
className="min-h-11 w-full sm:min-h-9"
|
||||
onClick={() => onLogDay(episode.id)}
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EpisodeGroup({
|
||||
title,
|
||||
parents,
|
||||
childrenByParent,
|
||||
onLogDay,
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
}: {
|
||||
title: string;
|
||||
parents: IllnessEpisodeDTO[];
|
||||
childrenByParent: Map<string, IllnessEpisodeDTO[]>;
|
||||
onLogDay: (id: string) => void;
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
}) {
|
||||
if (parents.length === 0) return null;
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{parents.map((episode) => (
|
||||
<EpisodeCard
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
flares={childrenByParent.get(episode.id)}
|
||||
resolving={resolving}
|
||||
onLogDay={onLogDay}
|
||||
onEdit={onEdit}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
@@ -117,6 +213,45 @@ export function IllnessView() {
|
||||
|
||||
const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null;
|
||||
|
||||
/**
|
||||
* Group the flat list into active / resolved, nesting flares under their
|
||||
* parent condition. A flare nests only when its parent lives in the same
|
||||
* status group (so an active flare under a resolved parent still surfaces as
|
||||
* its own active card rather than vanishing); otherwise it renders as a
|
||||
* standalone parent card in its own group.
|
||||
*/
|
||||
const grouped = useMemo(() => {
|
||||
const list = episodes ?? [];
|
||||
const byId = new Map(list.map((e) => [e.id, e]));
|
||||
const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null;
|
||||
|
||||
const activeParents: IllnessEpisodeDTO[] = [];
|
||||
const resolvedParents: IllnessEpisodeDTO[] = [];
|
||||
const childrenByParent = new Map<string, IllnessEpisodeDTO[]>();
|
||||
|
||||
for (const e of list) {
|
||||
const parent = e.parentConditionId
|
||||
? byId.get(e.parentConditionId)
|
||||
: undefined;
|
||||
const nestsUnderParent =
|
||||
parent !== undefined && isActive(parent) === isActive(e);
|
||||
|
||||
if (nestsUnderParent) {
|
||||
const bucket = childrenByParent.get(parent.id) ?? [];
|
||||
bucket.push(e);
|
||||
childrenByParent.set(parent.id, bucket);
|
||||
} else if (isActive(e)) {
|
||||
activeParents.push(e);
|
||||
} else {
|
||||
resolvedParents.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { activeParents, resolvedParents, childrenByParent };
|
||||
}, [episodes]);
|
||||
|
||||
const hasEpisodes = (episodes?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -140,26 +275,34 @@ export function IllnessView() {
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<div className={cn("grid gap-4 sm:grid-cols-2")}>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : episodes && episodes.length > 0 ? (
|
||||
) : hasEpisodes ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{episodes.map((episode) => (
|
||||
<EpisodeRow
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Retrospective summary sits above the list so a long history
|
||||
never buries it. */}
|
||||
<IllnessInsightsCard />
|
||||
|
||||
<EpisodeGroup
|
||||
title={t("illness.section.active")}
|
||||
parents={grouped.activeParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
/>
|
||||
<EpisodeGroup
|
||||
title={t("illness.section.resolved")}
|
||||
parents={grouped.resolvedParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
|
||||
@@ -73,6 +73,59 @@ describe("<VorsorgeSection> loading + empty", () => {
|
||||
expect(html).not.toContain('data-slot="vorsorge-loading"');
|
||||
});
|
||||
|
||||
it("renders a 'Log value' action for a measurement-linked reminder", () => {
|
||||
// v1.18.2 — a typed reminder's primary action opens the value-entry
|
||||
// form, surfaced as a "Log value" button (not a silent checkmark).
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "linked",
|
||||
label: "Measure blood pressure",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Log value");
|
||||
expect(html).not.toContain(">Done<");
|
||||
});
|
||||
|
||||
it("renders a 'Done' action for a free-text / self-planned reminder", () => {
|
||||
// v1.18.2 — a free-text reminder keeps the silent satisfy, surfaced as
|
||||
// a "Done" button + the "Self-planned" category badge.
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "planned",
|
||||
label: "Annual physical",
|
||||
measurementType: null,
|
||||
intervalDays: 365,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Done");
|
||||
expect(html).toContain("Self-planned");
|
||||
expect(html).not.toContain("Log value");
|
||||
});
|
||||
|
||||
it("translates a COACH-origin label i18n key and shows the neutral badge", () => {
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "c1",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.2 — Vorsorge (preventive-care) dashboard summary card.
|
||||
*
|
||||
* A compact chart-row card listing the next few due reminders with an
|
||||
* inline primary action per row — the same done-vs-capture branch the
|
||||
* dedicated `/vorsorge` page surfaces, scaled down for the dashboard.
|
||||
* A free-text / self-planned exam marks done silently; a measurement-
|
||||
* linked reminder opens the real measurement-entry form and satisfies
|
||||
* the reminder in the same action.
|
||||
*
|
||||
* Mirrors the medication compliance card's role on the chart row:
|
||||
* chart-row only, no strip tile. Self-skeletons while the read is in
|
||||
* flight and self-gates to a brief empty state otherwise.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
type MeasurementReminder,
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const SUMMARY_LIMIT = 3;
|
||||
|
||||
function relativeDueKey(
|
||||
nextDueAt: string | null,
|
||||
now: number,
|
||||
): { key: string; days: number } {
|
||||
if (!nextDueAt) return { key: "nextDue.none", days: 0 };
|
||||
const deltaDays = Math.round((new Date(nextDueAt).getTime() - now) / DAY_MS);
|
||||
if (deltaDays < 0) return { key: "overdueByDays", days: Math.abs(deltaDays) };
|
||||
if (deltaDays === 0) return { key: "nextDue.today", days: 0 };
|
||||
if (deltaDays === 1) return { key: "nextDue.tomorrow", days: 1 };
|
||||
return { key: "nextDue.inDays", days: deltaDays };
|
||||
}
|
||||
|
||||
function resolveLabel(
|
||||
reminder: MeasurementReminder,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (reminder.origin === "COACH") {
|
||||
const translated = t(reminder.label);
|
||||
return translated === reminder.label ? reminder.label : translated;
|
||||
}
|
||||
return reminder.label;
|
||||
}
|
||||
|
||||
export function VorsorgeDashboardCard() {
|
||||
const { t } = useTranslations();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders();
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
|
||||
const [captureFooterEl, setCaptureFooterEl] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Active reminders only (the manage toggles hide disabled ones from the
|
||||
// summary), most-urgent first as the API already sorts them.
|
||||
const upcoming = (reminders ?? [])
|
||||
.filter((r) => r.enabled)
|
||||
.slice(0, SUMMARY_LIMIT);
|
||||
|
||||
function onPrimaryAction(reminder: MeasurementReminder) {
|
||||
if (reminder.measurementType) {
|
||||
setCapturing(reminder);
|
||||
} else {
|
||||
satisfy.mutate(reminder.id);
|
||||
}
|
||||
}
|
||||
|
||||
function onCaptureSuccess() {
|
||||
const reminder = capturing;
|
||||
setCapturing(null);
|
||||
if (reminder) satisfy.mutate(reminder.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="vorsorge-dashboard-card" className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CalendarClock className="h-4 w-4" aria-hidden="true" />
|
||||
{t("measurementReminders.sectionTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : upcoming.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.dashboard.empty")}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/vorsorge">
|
||||
{t("measurementReminders.dashboard.openLink")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{upcoming.map((reminder) => {
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const isLinked = reminder.measurementType != null;
|
||||
return (
|
||||
<li
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-2.5"
|
||||
>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{resolveLabel(reminder, t)}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onPrimaryAction(reminder)}
|
||||
disabled={satisfy.isPending}
|
||||
>
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurementReminders.captureValue")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("measurementReminders.markDone")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={capturing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCapturing(null);
|
||||
}}
|
||||
title={t("measurementReminders.capture.title")}
|
||||
description={t("measurementReminders.capture.description")}
|
||||
footer={<div ref={setCaptureFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{capturing && (
|
||||
<MeasurementForm
|
||||
defaultType={capturing.measurementType ?? undefined}
|
||||
onSuccess={onCaptureSuccess}
|
||||
onCancel={() => setCapturing(null)}
|
||||
footerSlot={captureFooterEl}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,44 +3,67 @@
|
||||
/**
|
||||
* v1.17.1 — Vorsorge (preventive-care / measurement) reminder surface.
|
||||
*
|
||||
* Answers "wann muss ich was wo machen": a list of upcoming reminders
|
||||
* sorted by server-computed next-due, each card showing the label, the
|
||||
* cadence, the location, and a relative next-due badge. Per the project
|
||||
* Answers "wann muss ich was wo machen": a grid of upcoming reminders
|
||||
* sorted by server-computed next-due, each rendered as its OWN card that
|
||||
* mirrors the medication card grammar (neutral surface, header + kebab,
|
||||
* a next/last block, and a single bottom-pinned action). Per the project
|
||||
* rule the card stays NEUTRAL regardless of due state — no alarming
|
||||
* red/green tint; status reads through a discreet badge only.
|
||||
*
|
||||
* The server is authoritative for `nextDueAt`; this component renders it
|
||||
* relative to "now" but never recomputes the cadence.
|
||||
*
|
||||
* v1.18.1 — the create/edit form moved into a ResponsiveSheet with a
|
||||
* pinned Cancel/Save footer (matching Labs / Illness / Medications), an
|
||||
* Edit affordance wires the existing PATCH support, an enable/disable
|
||||
* toggle drives the `enabled` flag, and a COACH-origin reminder renders a
|
||||
* neutral provenance badge + an "until <date>" course-window line while
|
||||
* translating its i18n-key label.
|
||||
* v1.18.2 — rebuilt to mirror the MEDICATION module:
|
||||
* - each reminder is its own med-styled card (`VorsorgeCard`) reusing
|
||||
* `MedicationCardHeader` / `MedicationNextLastSlot` and the
|
||||
* `MedicationIntakeActions` bottom-pinned action treatment;
|
||||
* - the mark-done action branches on `measurementType`: a free-text /
|
||||
* self-planned exam keeps the silent satisfy ("Erledigt"); a
|
||||
* measurement-linked reminder opens the real `MeasurementForm`
|
||||
* ("Wert erfassen") so completing a BP reminder lands an actual
|
||||
* reading, then satisfies the reminder in the same user action;
|
||||
* - the create/edit form gains a first-due date (`anchorDate`) and a
|
||||
* custom cadence (free "every N days" + an RFC-5545 RRULE option);
|
||||
* - a wrench/customize affordance sits beside the page-variant Add,
|
||||
* hosting the per-reminder enable/disable toggles (moved off the
|
||||
* card so the card carries a single med-style kebab).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { CalendarClock, CheckCircle2, Pencil, Plus } from "lucide-react";
|
||||
import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { MedicationNextLastSlot } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
@@ -81,6 +104,10 @@ const TYPE_GROUPS = [
|
||||
] as const;
|
||||
|
||||
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
|
||||
// v1.18.2 — the sentinel the cadence picker writes when the user wants a
|
||||
// free interval or an RFC-5545 RRULE instead of a fixed preset.
|
||||
const CADENCE_CUSTOM = "custom";
|
||||
const CADENCE_RRULE = "rrule";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function relativeDueKey(
|
||||
@@ -115,11 +142,34 @@ function resolveReminderLabel(
|
||||
return reminder.label;
|
||||
}
|
||||
|
||||
/** ISO instant → the `<input type="date">` value (YYYY-MM-DD), local. */
|
||||
function toDateInputValue(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const offset = d.getTimezoneOffset();
|
||||
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** `<input type="date">` value → an ISO instant at local midnight, or null. */
|
||||
function fromDateInputValue(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
label: string;
|
||||
kind: "type" | "freeText";
|
||||
measurementType: string;
|
||||
intervalDays: number;
|
||||
/** A preset number-as-string, or the CADENCE_CUSTOM / CADENCE_RRULE sentinel. */
|
||||
cadenceChoice: string;
|
||||
/** Free "every N days" value, used when cadenceChoice === CADENCE_CUSTOM. */
|
||||
customIntervalDays: number;
|
||||
/** Raw RRULE, used when cadenceChoice === CADENCE_RRULE. */
|
||||
rrule: string;
|
||||
/** First-due date (YYYY-MM-DD), optional. Maps to `anchorDate`. */
|
||||
anchorDate: string;
|
||||
notifyHour: number;
|
||||
location: string;
|
||||
}
|
||||
@@ -128,7 +178,10 @@ const EMPTY_FORM: FormState = {
|
||||
label: "",
|
||||
kind: "type",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: 7,
|
||||
cadenceChoice: "7",
|
||||
customIntervalDays: 30,
|
||||
rrule: "FREQ=YEARLY",
|
||||
anchorDate: "",
|
||||
notifyHour: 9,
|
||||
location: "",
|
||||
};
|
||||
@@ -141,10 +194,8 @@ export function VorsorgeSection({
|
||||
/**
|
||||
* `"settings"` renders the compact `SettingsCardHeader` for the embedded
|
||||
* settings card; `"page"` renders the canonical feature-page header
|
||||
* (`<h1>` + subtitle + primary add button) so the standalone `/vorsorge`
|
||||
* surface matches its peers (labs, mood, medications, cycle). The
|
||||
* add-entry affordance is identical in both — a primary button with a
|
||||
* `Plus` glyph that opens the create sheet.
|
||||
* (`<h1>` + subtitle + primary add button + wrench) so the standalone
|
||||
* `/vorsorge` surface matches its peers (labs, mood, medications, cycle).
|
||||
*/
|
||||
variant?: "settings" | "page";
|
||||
}) {
|
||||
@@ -158,11 +209,18 @@ export function VorsorgeSection({
|
||||
null,
|
||||
);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
// The customize ("wrench") sheet — re-homes the per-reminder enable
|
||||
// toggles that no longer live on the card itself.
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
// The value-capture sheet: holds the reminder whose measurement we are
|
||||
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
|
||||
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
|
||||
const [captureFooterEl, setCaptureFooterEl] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Wall-clock anchor for the relative-due labels, captured once at mount
|
||||
// via a lazy state initializer so the impure Date.now() stays out of
|
||||
// render (the repo's purity + set-state-in-effect rules both reject the
|
||||
// alternatives). The relative labels are coarse (day granularity), so a
|
||||
// render. The relative labels are coarse (day granularity), so a
|
||||
// mount-time snapshot is plenty fresh.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
@@ -175,11 +233,26 @@ export function VorsorgeSection({
|
||||
}
|
||||
|
||||
function openEdit(reminder: MeasurementReminder) {
|
||||
// Resolve the stored cadence back onto the picker: a preset value if it
|
||||
// matches one, otherwise the custom-interval or RRULE branch.
|
||||
const interval = reminder.intervalDays;
|
||||
const cadenceChoice = reminder.rrule
|
||||
? CADENCE_RRULE
|
||||
: interval != null &&
|
||||
(INTERVAL_PRESETS as readonly number[]).includes(interval)
|
||||
? String(interval)
|
||||
: interval != null
|
||||
? CADENCE_CUSTOM
|
||||
: "7";
|
||||
setForm({
|
||||
label: resolveReminderLabel(reminder, t),
|
||||
kind: reminder.measurementType ? "type" : "freeText",
|
||||
measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS",
|
||||
intervalDays: reminder.intervalDays ?? 7,
|
||||
cadenceChoice,
|
||||
customIntervalDays:
|
||||
cadenceChoice === CADENCE_CUSTOM && interval != null ? interval : 30,
|
||||
rrule: reminder.rrule ?? "FREQ=YEARLY",
|
||||
anchorDate: toDateInputValue(reminder.anchorDate),
|
||||
notifyHour: reminder.notifyHour,
|
||||
location: reminder.location ?? "",
|
||||
});
|
||||
@@ -191,13 +264,32 @@ export function VorsorgeSection({
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
|
||||
// Resolve the picker state to the mutually-exclusive cadence pair the
|
||||
// schema expects (exactly one of intervalDays / rrule).
|
||||
function resolveCadence(): {
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
} {
|
||||
if (form.cadenceChoice === CADENCE_RRULE) {
|
||||
const trimmed = form.rrule.trim();
|
||||
return { intervalDays: null, rrule: trimmed || null };
|
||||
}
|
||||
if (form.cadenceChoice === CADENCE_CUSTOM) {
|
||||
return { intervalDays: form.customIntervalDays, rrule: null };
|
||||
}
|
||||
return { intervalDays: Number(form.cadenceChoice), rrule: null };
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const trimmed = form.label.trim();
|
||||
if (!trimmed) return;
|
||||
const { intervalDays, rrule } = resolveCadence();
|
||||
const body = {
|
||||
label: trimmed,
|
||||
measurementType: form.kind === "type" ? form.measurementType : null,
|
||||
intervalDays: form.intervalDays,
|
||||
intervalDays,
|
||||
rrule,
|
||||
anchorDate: fromDateInputValue(form.anchorDate),
|
||||
notifyHour: form.notifyHour,
|
||||
location: form.location.trim() || null,
|
||||
};
|
||||
@@ -211,10 +303,31 @@ export function VorsorgeSection({
|
||||
}
|
||||
}
|
||||
|
||||
const saving = create.isPending || update.isPending;
|
||||
// The mark-done action. A free-text / self-planned exam satisfies
|
||||
// silently; a measurement-linked reminder opens the real value-entry
|
||||
// form so the user logs an actual reading.
|
||||
function onPrimaryAction(reminder: MeasurementReminder) {
|
||||
if (reminder.measurementType) {
|
||||
setCapturing(reminder);
|
||||
} else {
|
||||
satisfy.mutate(reminder.id);
|
||||
}
|
||||
}
|
||||
|
||||
// The shared primary add-entry affordance — identical in both variants so
|
||||
// the control reads the same on the standalone page and the settings card.
|
||||
// On a successful measurement write, satisfy the reminder in the same
|
||||
// user action (forward-only guard makes the later cron a no-op) and
|
||||
// close the sheet.
|
||||
function onCaptureSuccess() {
|
||||
const reminder = capturing;
|
||||
setCapturing(null);
|
||||
if (reminder) satisfy.mutate(reminder.id);
|
||||
}
|
||||
|
||||
const saving = create.isPending || update.isPending;
|
||||
const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM;
|
||||
const cadenceRrule = form.cadenceChoice === CADENCE_RRULE;
|
||||
|
||||
// The shared primary add-entry affordance — identical in both variants.
|
||||
const addButton = (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -226,6 +339,30 @@ export function VorsorgeSection({
|
||||
</Button>
|
||||
);
|
||||
|
||||
// v1.18.2 — wrench/customize affordance, mirroring the labs page: a
|
||||
// ghost icon button beside Add opening the manage sheet.
|
||||
const wrenchButton = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={() => setManageOpen(true)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
{t("measurementReminders.manage.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
|
||||
{variant === "page" ? (
|
||||
@@ -241,7 +378,10 @@ export function VorsorgeSection({
|
||||
{t("measurementReminders.sectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{addButton}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{addButton}
|
||||
{wrenchButton}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SettingsCardHeader
|
||||
@@ -281,10 +421,9 @@ export function VorsorgeSection({
|
||||
>
|
||||
{/* v1.18.1 — the cadence/type/hour pickers use NativeSelect (not the
|
||||
Radix Select labs/illness use) deliberately: these are long,
|
||||
grouped, scalar option lists (24 hours, a 15-type grouped metric
|
||||
list) where the OS-native picker is faster on touch and avoids a
|
||||
tall scrolling Radix popover inside the bottom-sheet. The form
|
||||
fields below stay NativeSelect by design, not drift. */}
|
||||
grouped, scalar option lists where the OS-native picker is faster
|
||||
on touch and avoids a tall scrolling Radix popover inside the
|
||||
bottom-sheet. The form fields stay NativeSelect by design. */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-label">
|
||||
@@ -359,19 +498,22 @@ export function VorsorgeSection({
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="vorsorge-interval"
|
||||
value={String(form.intervalDays)}
|
||||
value={form.cadenceChoice}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
intervalDays: Number(e.target.value),
|
||||
}))
|
||||
setForm((f) => ({ ...f, cadenceChoice: e.target.value }))
|
||||
}
|
||||
>
|
||||
{INTERVAL_PRESETS.map((days) => (
|
||||
<option key={days} value={days}>
|
||||
<option key={days} value={String(days)}>
|
||||
{t("measurementReminders.cadence.everyNDays", { days })}
|
||||
</option>
|
||||
))}
|
||||
<option value={CADENCE_CUSTOM}>
|
||||
{t("measurementReminders.cadence.customInterval")}
|
||||
</option>
|
||||
<option value={CADENCE_RRULE}>
|
||||
{t("measurementReminders.cadence.rruleOption")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -394,6 +536,61 @@ export function VorsorgeSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cadenceCustom && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-custom-interval">
|
||||
{t("measurementReminders.form.customInterval")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vorsorge-custom-interval"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={form.customIntervalDays}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
customIntervalDays: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cadenceRrule && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-rrule">
|
||||
{t("measurementReminders.form.rrule")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vorsorge-rrule"
|
||||
value={form.rrule}
|
||||
maxLength={512}
|
||||
placeholder="FREQ=YEARLY"
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, rrule: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("measurementReminders.form.rruleHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-anchor">
|
||||
{t("measurementReminders.form.firstDue")}
|
||||
</Label>
|
||||
<Input
|
||||
id="vorsorge-anchor"
|
||||
type="date"
|
||||
value={form.anchorDate}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, anchorDate: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-location">
|
||||
{t("measurementReminders.form.location")}
|
||||
@@ -411,16 +608,76 @@ export function VorsorgeSection({
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* v1.18.2 — value-capture sheet: a measurement-linked reminder opens
|
||||
the real MeasurementForm pre-filled with its type, so completing a
|
||||
BP reminder logs an actual reading. On success the reminder is
|
||||
satisfied in the same action. */}
|
||||
<ResponsiveSheet
|
||||
open={capturing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCapturing(null);
|
||||
}}
|
||||
title={t("measurementReminders.capture.title")}
|
||||
description={t("measurementReminders.capture.description")}
|
||||
footer={<div ref={setCaptureFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{capturing && (
|
||||
<MeasurementForm
|
||||
defaultType={capturing.measurementType ?? undefined}
|
||||
onSuccess={onCaptureSuccess}
|
||||
onCancel={() => setCapturing(null)}
|
||||
footerSlot={captureFooterEl}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* v1.18.2 — customize sheet: re-homes the per-reminder enable/disable
|
||||
toggles (moved off the card so it carries a single med-style kebab). */}
|
||||
<ResponsiveSheet
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
title={t("measurementReminders.manage.title")}
|
||||
description={t("measurementReminders.manage.description")}
|
||||
>
|
||||
{(reminders?.length ?? 0) === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.manage.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{reminders?.map((reminder) => (
|
||||
<li
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm">
|
||||
{resolveReminderLabel(reminder, t)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate({ id: reminder.id, body: { enabled: next } })
|
||||
}
|
||||
disabled={update.isPending}
|
||||
aria-label={t("measurementReminders.enabledToggleAria")}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3" data-slot="vorsorge-loading">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Card key={i} aria-hidden="true">
|
||||
<CardContent className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24 shrink-0" />
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
data-slot="vorsorge-loading"
|
||||
>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Card key={i} aria-hidden="true" className="h-full gap-3">
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -440,22 +697,24 @@ export function VorsorgeSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{reminders?.map((reminder) => (
|
||||
<VorsorgeCard
|
||||
key={reminder.id}
|
||||
reminder={reminder}
|
||||
now={now}
|
||||
onSatisfy={() => satisfy.mutate(reminder.id)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
onToggleEnabled={(next) =>
|
||||
update.mutate({ id: reminder.id, body: { enabled: next } })
|
||||
}
|
||||
busy={satisfy.isPending || remove.isPending || update.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{!isLoading && (reminders?.length ?? 0) > 0 && (
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
{reminders?.map((reminder) => (
|
||||
<li key={reminder.id} className="contents">
|
||||
<VorsorgeCard
|
||||
reminder={reminder}
|
||||
now={now}
|
||||
onPrimaryAction={() => onPrimaryAction(reminder)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
busy={
|
||||
satisfy.isPending || remove.isPending || update.isPending
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -463,22 +722,21 @@ export function VorsorgeSection({
|
||||
function VorsorgeCard({
|
||||
reminder,
|
||||
now,
|
||||
onSatisfy,
|
||||
onPrimaryAction,
|
||||
onRemove,
|
||||
onEdit,
|
||||
onToggleEnabled,
|
||||
busy,
|
||||
}: {
|
||||
reminder: MeasurementReminder;
|
||||
now: number;
|
||||
onSatisfy: () => void;
|
||||
onPrimaryAction: () => void;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const cadence =
|
||||
reminder.intervalDays != null
|
||||
@@ -489,91 +747,151 @@ function VorsorgeCard({
|
||||
? t("measurementReminders.cadence.custom")
|
||||
: "";
|
||||
const isCoach = reminder.origin === "COACH";
|
||||
const isLinked = reminder.measurementType != null;
|
||||
|
||||
// Category-style header badge: the measurement label, or "self-planned"
|
||||
// for a free-text exam — mirroring the medication card's class badge.
|
||||
const categoryLabel = isLinked
|
||||
? t(`measurementReminders.types.${reminder.measurementType}`)
|
||||
: t("measurementReminders.selfPlanned");
|
||||
|
||||
const stateBadges =
|
||||
isCoach || !reminder.enabled ? (
|
||||
<>
|
||||
{isCoach && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.originCoach")}
|
||||
</Badge>
|
||||
)}
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
// Single med-style kebab: Edit + Delete. The Delete item is the shared
|
||||
// confirm-on-delete control rendered as a full-width menu row.
|
||||
const headerActions = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={onEdit} disabled={busy}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("measurementReminders.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onSelect={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("measurementReminders.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
// Next/last block in the med-card grammar. The "next" value carries the
|
||||
// discreet neutral due badge + cadence; the "last" value is the last
|
||||
// satisfied date.
|
||||
const nextValue = (
|
||||
<span className="inline-flex flex-wrap items-center justify-end gap-x-2 gap-y-1">
|
||||
<Badge variant="secondary">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
{cadence && <span className="text-muted-foreground">{cadence}</span>}
|
||||
</span>
|
||||
);
|
||||
const lastValue = reminder.lastSatisfiedAt
|
||||
? fmt.date(new Date(reminder.lastSatisfiedAt))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li>
|
||||
{/* NEUTRAL card — no status-driven colour. The due state reads only
|
||||
through the discreet badge below. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{resolveReminderLabel(reminder, t)}
|
||||
</span>
|
||||
{isCoach && (
|
||||
// Neutral provenance badge — no status colour per the rule.
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.originCoach")}
|
||||
</Badge>
|
||||
)}
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-1">
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
disabled={busy}
|
||||
aria-label={t("measurementReminders.enabledToggleAria")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSatisfy}
|
||||
disabled={busy}
|
||||
aria-label={t("measurementReminders.markDone")}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-4 w-4" />
|
||||
{t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onEdit}
|
||||
disabled={busy}
|
||||
className="size-9"
|
||||
aria-label={t("measurementReminders.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={onRemove}
|
||||
title={t("measurementReminders.deleteConfirmTitle")}
|
||||
description={t("measurementReminders.deleteConfirmDescription")}
|
||||
confirmLabel={t("measurementReminders.delete")}
|
||||
className="size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<>
|
||||
{/* NEUTRAL card — no status-driven colour. Due state reads only through
|
||||
the discreet badge below. Shares the medication card shell + tokens. */}
|
||||
<Card className="h-full gap-3 md:gap-3">
|
||||
<MedicationCardHeader
|
||||
name={resolveReminderLabel(reminder, t)}
|
||||
dose=""
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={stateBadges}
|
||||
actions={headerActions}
|
||||
/>
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
<MedicationNextLastSlot next={nextValue} last={lastValue} />
|
||||
|
||||
{reminder.endsOn && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
{cadence}
|
||||
{t("measurementReminders.until", {
|
||||
date: fmt.date(new Date(reminder.endsOn)),
|
||||
})}
|
||||
</p>
|
||||
{reminder.endsOn && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.until", {
|
||||
date: fmt.date(new Date(reminder.endsOn)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
)}
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bottom-pinned single primary action — branches on the link state:
|
||||
a self-planned exam marks done; a measurement-linked reminder
|
||||
opens the value-entry form. */}
|
||||
<div className="mt-auto pt-0">
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11 w-full"
|
||||
onClick={onPrimaryAction}
|
||||
disabled={busy}
|
||||
>
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurementReminders.captureValue")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("measurementReminders.markDone")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("measurementReminders.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("measurementReminders.deleteConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onRemove}>
|
||||
{t("measurementReminders.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -146,6 +146,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
wristTemperature: "measurements.typeWristTemperature",
|
||||
falls: "measurements.typeFallCount",
|
||||
walkingSteadiness: "measurements.typeWalkingSteadiness",
|
||||
// v1.18.2 — Vorsorge preventive-care summary card.
|
||||
vorsorge: "measurementReminders.sectionTitle",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
@@ -443,13 +443,28 @@ describe("resolveDashboardLayout() — heroVisible", () => {
|
||||
* iOS-only ids extend it without touching the writable PUT enum.
|
||||
*/
|
||||
describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => {
|
||||
it("carries exactly 35 distinct ids (24 server-known + 11 iOS-only)", () => {
|
||||
it("carries exactly 36 distinct ids (25 server-known + 11 iOS-only)", () => {
|
||||
// v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so
|
||||
// the server-known set grew 16 → 24 and the catalogue 27 → 35.
|
||||
expect(DASHBOARD_WIDGET_IDS).toHaveLength(24);
|
||||
// v1.18.2 — the Vorsorge summary widget added one server-known id
|
||||
// (24 → 25), so the catalogue grew 35 → 36.
|
||||
expect(DASHBOARD_WIDGET_IDS).toHaveLength(25);
|
||||
expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(11);
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(35);
|
||||
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(35);
|
||||
expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(36);
|
||||
expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(36);
|
||||
});
|
||||
|
||||
it("registers the Vorsorge widget default-off on both surfaces", () => {
|
||||
// v1.18.2 — Vorsorge becomes a first-class dashboard widget like
|
||||
// medications: chart-row only, default-invisible so existing
|
||||
// dashboards are unchanged until the user opts in.
|
||||
expect(DASHBOARD_WIDGET_IDS).toContain("vorsorge");
|
||||
const entry = DEFAULT_DASHBOARD_LAYOUT.widgets.find(
|
||||
(w) => w.id === "vorsorge",
|
||||
);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.visible).toBe(false);
|
||||
expect(entry?.tileVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("is a superset of the server-known ids in declaration order", () => {
|
||||
@@ -590,17 +605,17 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
|
||||
expect(ids).not.toContain("totally-made-up");
|
||||
});
|
||||
|
||||
it("keeps the default layout at the 24 web tiles (no iOS-only seeded)", () => {
|
||||
it("keeps the default layout at the 25 web tiles (no iOS-only seeded)", () => {
|
||||
const ids = DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id);
|
||||
expect(ids).toHaveLength(24);
|
||||
expect(ids).toHaveLength(25);
|
||||
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
|
||||
expect(ids).not.toContain(iosId);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT auto-append iOS-only ids when a web-only layout is read", () => {
|
||||
// A web account that saved only `weight` must auto-upgrade to the 24
|
||||
// web defaults — never to the 35 catalogue. iOS-only ids appear only
|
||||
// A web account that saved only `weight` must auto-upgrade to the 25
|
||||
// web defaults — never to the 36 catalogue. iOS-only ids appear only
|
||||
// once a native client has explicitly sent them.
|
||||
const partial = {
|
||||
version: 1,
|
||||
@@ -608,7 +623,7 @@ describe("resolveDashboardLayout() — iOS-only id retention (v1.7.0)", () => {
|
||||
};
|
||||
const resolved = resolveDashboardLayout(partial);
|
||||
const ids = resolved.widgets.map((w) => w.id);
|
||||
expect(ids).toHaveLength(24);
|
||||
expect(ids).toHaveLength(25);
|
||||
for (const iosId of DASHBOARD_IOS_ONLY_WIDGET_IDS) {
|
||||
expect(ids).not.toContain(iosId);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ export const DASHBOARD_WIDGET_IDS = [
|
||||
"wristTemperature",
|
||||
"falls",
|
||||
"walkingSteadiness",
|
||||
// v1.18.2 — Vorsorge (preventive-care reminders) becomes a first-class
|
||||
// dashboard widget, exactly like `medications`: a chart-row summary card
|
||||
// (no strip tile). Default-off so existing dashboards are unchanged; the
|
||||
// user opts in via Settings → Dashboard. Always-on data surface (no
|
||||
// toggleable module gate), so it behaves as a core opt-in tile.
|
||||
"vorsorge",
|
||||
] as const;
|
||||
|
||||
export type DashboardWidgetId = (typeof DASHBOARD_WIDGET_IDS)[number];
|
||||
@@ -402,6 +408,9 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
{ id: "wristTemperature", visible: false, tileVisible: false, order: 21 },
|
||||
{ id: "falls", visible: false, tileVisible: false, order: 22 },
|
||||
{ id: "walkingSteadiness", visible: false, tileVisible: false, order: 23 },
|
||||
// v1.18.2 — Vorsorge summary card. Chart-row only (no strip tile), so
|
||||
// `tileVisible` is forced false; default-invisible until opt-in.
|
||||
{ id: "vorsorge", visible: false, tileVisible: false, order: 24 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -731,11 +731,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () =>
|
||||
isFullyCovered.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("emits all 35 catalogue ids with visibility + order", async () => {
|
||||
it("emits all 36 catalogue ids with visibility + order", async () => {
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser());
|
||||
expect(snap.layoutCatalogue).toHaveLength(35);
|
||||
expect(snap.layoutCatalogue).toHaveLength(36);
|
||||
const ids = new Set(snap.layoutCatalogue.map((w) => w.id));
|
||||
expect(ids.size).toBe(35);
|
||||
expect(ids.size).toBe(36);
|
||||
// iOS-only ids appended default-invisible.
|
||||
const hrv = snap.layoutCatalogue.find((w) => w.id === "hrv");
|
||||
expect(hrv).toBeDefined();
|
||||
@@ -806,7 +806,7 @@ describe("buildDashboardSnapshot — additive proof", () => {
|
||||
expect(snap.layout).toHaveProperty("version");
|
||||
expect(Array.isArray(snap.layout.widgets)).toBe(true);
|
||||
// Resolved layout still carries only server-known widget ids.
|
||||
expect(snap.layout.widgets.length).toBeLessThanOrEqual(24);
|
||||
expect(snap.layout.widgets.length).toBeLessThanOrEqual(25);
|
||||
|
||||
// tiles shape unchanged.
|
||||
expect(snap.tiles).toHaveProperty("summaries");
|
||||
|
||||
Reference in new issue
Block a user