mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(modules): unify Vorsorge, Illness and Labs with the medication pattern
- add a wrench beside each module's add button linking to the module's own settings page; rename every add button to a plain "hinzufügen" - give each module a settings page: view (cards/list) + manual reorder; Labs also gets a sort-order choice (by last reading) and biomarker CRUD/reorder - collapse the Illness "abgeschlossen" list by default - rebuild the Vorsorge card on the medication card's building blocks: a green "Jetzt messen" action, the interval as a chip beside the name, and an in-interval progress signal; drop the kebab "anpassen" and stray actions - persist per-module view/order/sort client-side; add reverse-coverage-clean strings across all six locales
This commit is contained in:
1 parent
ae3091df9e
commit
267dcad13d
22 files changed
+1834
-273
No files matched your search
+50
-5
@@ -3697,6 +3697,10 @@
|
||||
"body": "Das Onboarding hast du bereits abgeschlossen. Möchtest du etwas anpassen, findest du alles in den Einstellungen.",
|
||||
"cta": "Zum Dashboard"
|
||||
},
|
||||
"disclaimer": {
|
||||
"acknowledge": "Mir ist klar: HealthLog ist ein privates Tracking-Tool, keine klinische Bewertung und keine Diagnose. Bei Sorgen wende ich mich an meine Ärztin oder meinen Arzt, nicht an diese App.",
|
||||
"learnMore": "Mehr erfahren"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Willkommen bei HealthLog",
|
||||
"carouselLabel": "Begrüßungs-Karussell",
|
||||
@@ -6379,13 +6383,26 @@
|
||||
"until": "Bis {date}",
|
||||
"selfPlanned": "Selbstgeplant",
|
||||
"captureValue": "Wert erfassen",
|
||||
"measureNow": "Jetzt messen",
|
||||
"nextDueLabel": "Nächste Fälligkeit:",
|
||||
"lastDoneLabel": "Zuletzt erledigt:",
|
||||
"customize": "Vorsorge anpassen",
|
||||
"adherence": {
|
||||
"label": "Im Intervall",
|
||||
"onTrack": "Im Plan",
|
||||
"due": "Jetzt fällig"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Vorsorge anpassen",
|
||||
"description": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten.",
|
||||
"back": "Zurück zur Vorsorge"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Messwert erfassen",
|
||||
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Anpassen",
|
||||
"title": "Erinnerungen anpassen",
|
||||
"title": "Erinnerungen verwalten",
|
||||
"description": "Einzelne Erinnerungen aktivieren oder deaktivieren.",
|
||||
"empty": "Noch keine Erinnerungen zum Anpassen."
|
||||
},
|
||||
@@ -6394,6 +6411,17 @@
|
||||
"openLink": "Vorsorge öffnen"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Ansicht",
|
||||
"viewCards": "Kartenansicht",
|
||||
"viewList": "Listenansicht",
|
||||
"viewHeading": "Ansicht",
|
||||
"viewDescription": "Lege fest, ob die Einträge als Karten oder als kompakte Liste angezeigt werden.",
|
||||
"reorder": {
|
||||
"heading": "Reihenfolge",
|
||||
"empty": "Noch keine Einträge zum Sortieren."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laborwerte",
|
||||
"subtitle": "Blutwerte und Biomarker",
|
||||
@@ -6409,6 +6437,20 @@
|
||||
"deletedToast": "Wert gelöscht",
|
||||
"deleteError": "Wert konnte nicht gelöscht werden",
|
||||
"loadError": "Laborwerte konnten nicht geladen werden",
|
||||
"customize": "Laborwerte anpassen",
|
||||
"settings": {
|
||||
"title": "Laborwerte anpassen",
|
||||
"description": "Ansicht, Sortierung und Biomarker verwalten.",
|
||||
"back": "Zurück zu den Laborwerten"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Sortierung",
|
||||
"label": "Reihenfolge der Werte",
|
||||
"recentDesc": "Neueste Messung zuerst",
|
||||
"recentAsc": "Älteste Messung zuerst",
|
||||
"manual": "Manuell sortieren",
|
||||
"hint": "Bei manueller Sortierung kannst du die Biomarker unten in deine eigene Reihenfolge bringen."
|
||||
},
|
||||
"range": {
|
||||
"inRange": "Im Bereich",
|
||||
"below": "Unter Bereich",
|
||||
@@ -6456,9 +6498,7 @@
|
||||
}
|
||||
},
|
||||
"biomarker": {
|
||||
"manage": "Biomarker",
|
||||
"manageTitle": "Biomarker",
|
||||
"manageSheetDescription": "Lege die Marker an, die du verfolgst. Jeder wird einmal mit Einheit und Referenzbereich eingerichtet.",
|
||||
"managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.",
|
||||
"define": "Anlegen",
|
||||
"defineFirst": "Ersten Biomarker anlegen",
|
||||
@@ -6612,7 +6652,12 @@
|
||||
"illness": {
|
||||
"title": "Krankheitstagebuch",
|
||||
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
|
||||
"disclaimer": "Ein persönliches Tagebuch — kein Medizinprodukt. Es stellt keine Diagnose und keine Prognose.",
|
||||
"customize": "Krankheitstagebuch anpassen",
|
||||
"settings": {
|
||||
"title": "Krankheitstagebuch anpassen",
|
||||
"description": "Ansicht und Reihenfolge der Episoden verwalten.",
|
||||
"back": "Zurück zum Krankheitstagebuch"
|
||||
},
|
||||
"newEpisode": "Neue Episode",
|
||||
"logDay": "Tag erfassen",
|
||||
"markRecovered": "Als genesen markieren",
|
||||
|
||||
+50
-5
@@ -3697,6 +3697,10 @@
|
||||
"body": "You've already finished onboarding. To adjust anything, head to Settings — everything you set up is editable from there.",
|
||||
"cta": "Open dashboard"
|
||||
},
|
||||
"disclaimer": {
|
||||
"acknowledge": "I understand: HealthLog is a private tracking tool, not a clinical assessment or diagnosis. If I'm worried I'll talk to my doctor, not this app.",
|
||||
"learnMore": "Learn more"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Welcome to HealthLog",
|
||||
"carouselLabel": "Welcome carousel",
|
||||
@@ -6379,13 +6383,26 @@
|
||||
"until": "Until {date}",
|
||||
"selfPlanned": "Self-planned",
|
||||
"captureValue": "Log value",
|
||||
"measureNow": "Measure now",
|
||||
"nextDueLabel": "Next due:",
|
||||
"lastDoneLabel": "Last done:",
|
||||
"customize": "Customize preventive care",
|
||||
"adherence": {
|
||||
"label": "Within interval",
|
||||
"onTrack": "On track",
|
||||
"due": "Due now"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Customize preventive care",
|
||||
"description": "Manage the view, order, and individual reminders.",
|
||||
"back": "Back to preventive care"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Log measurement",
|
||||
"description": "Enter the reading; the reminder is marked done."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Customize",
|
||||
"title": "Customize reminders",
|
||||
"title": "Manage reminders",
|
||||
"description": "Enable or disable individual reminders.",
|
||||
"empty": "No reminders to customize yet."
|
||||
},
|
||||
@@ -6394,6 +6411,17 @@
|
||||
"openLink": "Open preventive care"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "View",
|
||||
"viewCards": "Card view",
|
||||
"viewList": "List view",
|
||||
"viewHeading": "View",
|
||||
"viewDescription": "Choose whether entries show as cards or as a compact list.",
|
||||
"reorder": {
|
||||
"heading": "Order",
|
||||
"empty": "No entries to reorder yet."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Lab results",
|
||||
"subtitle": "Bloodwork and biomarker readings",
|
||||
@@ -6409,6 +6437,20 @@
|
||||
"deletedToast": "Result deleted",
|
||||
"deleteError": "Could not delete the result",
|
||||
"loadError": "Could not load lab results",
|
||||
"customize": "Customize labs",
|
||||
"settings": {
|
||||
"title": "Customize labs",
|
||||
"description": "Manage the view, sort order, and biomarkers.",
|
||||
"back": "Back to labs"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Sort order",
|
||||
"label": "Order of values",
|
||||
"recentDesc": "Most recent reading first",
|
||||
"recentAsc": "Oldest reading first",
|
||||
"manual": "Sort manually",
|
||||
"hint": "With manual sorting you can drag the biomarkers below into your own order."
|
||||
},
|
||||
"range": {
|
||||
"inRange": "In range",
|
||||
"below": "Below range",
|
||||
@@ -6456,9 +6498,7 @@
|
||||
}
|
||||
},
|
||||
"biomarker": {
|
||||
"manage": "Biomarkers",
|
||||
"manageTitle": "Biomarkers",
|
||||
"manageSheetDescription": "Define the markers you track. Each is set up once with its unit and reference range.",
|
||||
"managerDescription": "Define a marker once — its unit and range are reused for every reading.",
|
||||
"define": "Define",
|
||||
"defineFirst": "Define your first biomarker",
|
||||
@@ -6612,7 +6652,12 @@
|
||||
"illness": {
|
||||
"title": "Illness journal",
|
||||
"subtitle": "A retrospective log of your illness and condition episodes.",
|
||||
"disclaimer": "A personal journal — not a medical device. It does not diagnose or predict.",
|
||||
"customize": "Customize illness journal",
|
||||
"settings": {
|
||||
"title": "Customize illness journal",
|
||||
"description": "Manage the view and order of episodes.",
|
||||
"back": "Back to the illness journal"
|
||||
},
|
||||
"newEpisode": "New episode",
|
||||
"logDay": "Log a day",
|
||||
"markRecovered": "Mark recovered",
|
||||
|
||||
+50
-5
@@ -3697,6 +3697,10 @@
|
||||
"body": "Ya completaste la configuración inicial. Para cambiar algo, abre Ajustes — todo lo que configuraste se puede editar desde ahí.",
|
||||
"cta": "Abrir el panel"
|
||||
},
|
||||
"disclaimer": {
|
||||
"acknowledge": "Lo entiendo: HealthLog es una herramienta privada de seguimiento, no una valoración clínica ni un diagnóstico. Si me preocupa algo, hablaré con mi médico, no con esta app.",
|
||||
"learnMore": "Más información"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenido a HealthLog",
|
||||
"carouselLabel": "Carrusel de bienvenida",
|
||||
@@ -6379,13 +6383,26 @@
|
||||
"until": "Hasta {date}",
|
||||
"selfPlanned": "Autoplanificado",
|
||||
"captureValue": "Registrar valor",
|
||||
"measureNow": "Medir ahora",
|
||||
"nextDueLabel": "Próximo vencimiento:",
|
||||
"lastDoneLabel": "Última vez:",
|
||||
"customize": "Personalizar prevención",
|
||||
"adherence": {
|
||||
"label": "Dentro del intervalo",
|
||||
"onTrack": "Al día",
|
||||
"due": "Toca ahora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Personalizar prevención",
|
||||
"description": "Gestiona la vista, el orden y los recordatorios individuales.",
|
||||
"back": "Volver a prevención"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Registrar medición",
|
||||
"description": "Introduce el valor; el recordatorio se marca como hecho."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Personalizar",
|
||||
"title": "Personalizar recordatorios",
|
||||
"title": "Gestionar recordatorios",
|
||||
"description": "Activa o desactiva recordatorios individuales.",
|
||||
"empty": "Aún no hay recordatorios para personalizar."
|
||||
},
|
||||
@@ -6394,6 +6411,17 @@
|
||||
"openLink": "Abrir prevención"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Vista",
|
||||
"viewCards": "Vista de tarjetas",
|
||||
"viewList": "Vista de lista",
|
||||
"viewHeading": "Vista",
|
||||
"viewDescription": "Elige si las entradas se muestran como tarjetas o como lista compacta.",
|
||||
"reorder": {
|
||||
"heading": "Orden",
|
||||
"empty": "Aún no hay entradas para ordenar."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Resultados de análisis",
|
||||
"subtitle": "Analíticas de sangre y biomarcadores",
|
||||
@@ -6409,6 +6437,20 @@
|
||||
"deletedToast": "Resultado eliminado",
|
||||
"deleteError": "No se pudo eliminar el resultado",
|
||||
"loadError": "No se pudieron cargar los resultados",
|
||||
"customize": "Personalizar laboratorio",
|
||||
"settings": {
|
||||
"title": "Personalizar laboratorio",
|
||||
"description": "Gestiona la vista, el orden y los biomarcadores.",
|
||||
"back": "Volver al laboratorio"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Orden",
|
||||
"label": "Orden de los valores",
|
||||
"recentDesc": "Medición más reciente primero",
|
||||
"recentAsc": "Medición más antigua primero",
|
||||
"manual": "Ordenar manualmente",
|
||||
"hint": "Con el orden manual puedes arrastrar los biomarcadores de abajo en tu propio orden."
|
||||
},
|
||||
"range": {
|
||||
"inRange": "En rango",
|
||||
"below": "Por debajo",
|
||||
@@ -6456,9 +6498,7 @@
|
||||
}
|
||||
},
|
||||
"biomarker": {
|
||||
"manage": "Biomarcadores",
|
||||
"manageTitle": "Biomarcadores",
|
||||
"manageSheetDescription": "Define los marcadores que sigues. Cada uno se configura una vez con su unidad y rango de referencia.",
|
||||
"managerDescription": "Define un marcador una vez: su unidad y rango se reutilizan en cada medición.",
|
||||
"define": "Definir",
|
||||
"defineFirst": "Define tu primer biomarcador",
|
||||
@@ -6612,7 +6652,12 @@
|
||||
"illness": {
|
||||
"title": "Diario de enfermedad",
|
||||
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
|
||||
"disclaimer": "Un diario personal, no un dispositivo médico. No diagnostica ni predice.",
|
||||
"customize": "Personalizar diario de enfermedad",
|
||||
"settings": {
|
||||
"title": "Personalizar diario de enfermedad",
|
||||
"description": "Gestiona la vista y el orden de los episodios.",
|
||||
"back": "Volver al diario de enfermedad"
|
||||
},
|
||||
"newEpisode": "Nuevo episodio",
|
||||
"logDay": "Registrar un día",
|
||||
"markRecovered": "Marcar como recuperado",
|
||||
|
||||
+50
-5
@@ -3697,6 +3697,10 @@
|
||||
"body": "Vous avez déjà terminé la configuration. Pour modifier un élément, ouvrez les paramètres — tout reste éditable.",
|
||||
"cta": "Ouvrir le tableau de bord"
|
||||
},
|
||||
"disclaimer": {
|
||||
"acknowledge": "Je comprends : HealthLog est un outil de suivi privé, pas une évaluation clinique ni un diagnostic. En cas d'inquiétude, je consulte mon médecin, pas cette application.",
|
||||
"learnMore": "En savoir plus"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Bienvenue dans HealthLog",
|
||||
"carouselLabel": "Carrousel de bienvenue",
|
||||
@@ -6379,13 +6383,26 @@
|
||||
"until": "Jusqu'au {date}",
|
||||
"selfPlanned": "Auto-planifié",
|
||||
"captureValue": "Enregistrer la valeur",
|
||||
"measureNow": "Mesurer maintenant",
|
||||
"nextDueLabel": "Prochaine échéance :",
|
||||
"lastDoneLabel": "Dernière fois :",
|
||||
"customize": "Personnaliser la prévention",
|
||||
"adherence": {
|
||||
"label": "Dans l'intervalle",
|
||||
"onTrack": "Dans les temps",
|
||||
"due": "À faire maintenant"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Personnaliser la prévention",
|
||||
"description": "Gérer l'affichage, l'ordre et les rappels individuels.",
|
||||
"back": "Retour à la prévention"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Enregistrer une mesure",
|
||||
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Personnaliser",
|
||||
"title": "Personnaliser les rappels",
|
||||
"title": "Gérer les rappels",
|
||||
"description": "Activer ou désactiver des rappels individuels.",
|
||||
"empty": "Aucun rappel à personnaliser pour l'instant."
|
||||
},
|
||||
@@ -6394,6 +6411,17 @@
|
||||
"openLink": "Ouvrir la prévention"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Affichage",
|
||||
"viewCards": "Vue en cartes",
|
||||
"viewList": "Vue en liste",
|
||||
"viewHeading": "Affichage",
|
||||
"viewDescription": "Choisissez si les entrées s'affichent en cartes ou en liste compacte.",
|
||||
"reorder": {
|
||||
"heading": "Ordre",
|
||||
"empty": "Aucune entrée à réordonner pour l'instant."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Résultats d'analyses",
|
||||
"subtitle": "Analyses sanguines et biomarqueurs",
|
||||
@@ -6409,6 +6437,20 @@
|
||||
"deletedToast": "Résultat supprimé",
|
||||
"deleteError": "Impossible de supprimer le résultat",
|
||||
"loadError": "Impossible de charger les résultats",
|
||||
"customize": "Personnaliser les analyses",
|
||||
"settings": {
|
||||
"title": "Personnaliser les analyses",
|
||||
"description": "Gérer l'affichage, le tri et les biomarqueurs.",
|
||||
"back": "Retour aux analyses"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Tri",
|
||||
"label": "Ordre des valeurs",
|
||||
"recentDesc": "Mesure la plus récente d'abord",
|
||||
"recentAsc": "Mesure la plus ancienne d'abord",
|
||||
"manual": "Trier manuellement",
|
||||
"hint": "En tri manuel, vous pouvez glisser les biomarqueurs ci-dessous dans votre propre ordre."
|
||||
},
|
||||
"range": {
|
||||
"inRange": "Dans la norme",
|
||||
"below": "Sous la norme",
|
||||
@@ -6456,9 +6498,7 @@
|
||||
}
|
||||
},
|
||||
"biomarker": {
|
||||
"manage": "Biomarqueurs",
|
||||
"manageTitle": "Biomarqueurs",
|
||||
"manageSheetDescription": "Définissez les marqueurs que vous suivez. Chacun est configuré une fois avec son unité et sa plage de référence.",
|
||||
"managerDescription": "Définissez un marqueur une fois — son unité et sa plage sont réutilisées pour chaque mesure.",
|
||||
"define": "Définir",
|
||||
"defineFirst": "Définir votre premier biomarqueur",
|
||||
@@ -6612,7 +6652,12 @@
|
||||
"illness": {
|
||||
"title": "Journal de maladie",
|
||||
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
|
||||
"disclaimer": "Un journal personnel, pas un dispositif médical. Il ne diagnostique ni ne prédit.",
|
||||
"customize": "Personnaliser le journal de maladie",
|
||||
"settings": {
|
||||
"title": "Personnaliser le journal de maladie",
|
||||
"description": "Gérer l'affichage et l'ordre des épisodes.",
|
||||
"back": "Retour au journal de maladie"
|
||||
},
|
||||
"newEpisode": "Nouvel épisode",
|
||||
"logDay": "Enregistrer un jour",
|
||||
"markRecovered": "Marquer comme rétabli",
|
||||
|
||||
+50
-5
@@ -3697,6 +3697,10 @@
|
||||
"body": "Hai già completato l'onboarding. Per modificare qualcosa apri le impostazioni — tutto ciò che hai configurato è ancora modificabile.",
|
||||
"cta": "Apri la dashboard"
|
||||
},
|
||||
"disclaimer": {
|
||||
"acknowledge": "Ho capito: HealthLog è uno strumento di monitoraggio privato, non una valutazione clinica né una diagnosi. Se sono preoccupato ne parlo con il medico, non con questa app.",
|
||||
"learnMore": "Scopri di più"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Benvenuto in HealthLog",
|
||||
"carouselLabel": "Carosello di benvenuto",
|
||||
@@ -6379,13 +6383,26 @@
|
||||
"until": "Fino al {date}",
|
||||
"selfPlanned": "Autopianificato",
|
||||
"captureValue": "Registra valore",
|
||||
"measureNow": "Misura ora",
|
||||
"nextDueLabel": "Prossima scadenza:",
|
||||
"lastDoneLabel": "Ultima volta:",
|
||||
"customize": "Personalizza prevenzione",
|
||||
"adherence": {
|
||||
"label": "Nell'intervallo",
|
||||
"onTrack": "In linea",
|
||||
"due": "Da fare ora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Personalizza prevenzione",
|
||||
"description": "Gestisci la vista, l'ordine e i singoli promemoria.",
|
||||
"back": "Torna alla prevenzione"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Registra misurazione",
|
||||
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Personalizza",
|
||||
"title": "Personalizza promemoria",
|
||||
"title": "Gestisci promemoria",
|
||||
"description": "Attiva o disattiva singoli promemoria.",
|
||||
"empty": "Nessun promemoria da personalizzare."
|
||||
},
|
||||
@@ -6394,6 +6411,17 @@
|
||||
"openLink": "Apri prevenzione"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Vista",
|
||||
"viewCards": "Vista a schede",
|
||||
"viewList": "Vista a elenco",
|
||||
"viewHeading": "Vista",
|
||||
"viewDescription": "Scegli se le voci appaiono come schede o come elenco compatto.",
|
||||
"reorder": {
|
||||
"heading": "Ordine",
|
||||
"empty": "Nessuna voce da riordinare."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Risultati degli esami",
|
||||
"subtitle": "Analisi del sangue e biomarcatori",
|
||||
@@ -6409,6 +6437,20 @@
|
||||
"deletedToast": "Risultato eliminato",
|
||||
"deleteError": "Impossibile eliminare il risultato",
|
||||
"loadError": "Impossibile caricare i risultati",
|
||||
"customize": "Personalizza esami",
|
||||
"settings": {
|
||||
"title": "Personalizza esami",
|
||||
"description": "Gestisci la vista, l'ordinamento e i biomarcatori.",
|
||||
"back": "Torna agli esami"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Ordinamento",
|
||||
"label": "Ordine dei valori",
|
||||
"recentDesc": "Misurazione più recente prima",
|
||||
"recentAsc": "Misurazione più vecchia prima",
|
||||
"manual": "Ordina manualmente",
|
||||
"hint": "Con l'ordinamento manuale puoi trascinare i biomarcatori qui sotto nel tuo ordine."
|
||||
},
|
||||
"range": {
|
||||
"inRange": "Nella norma",
|
||||
"below": "Sotto la norma",
|
||||
@@ -6456,9 +6498,7 @@
|
||||
}
|
||||
},
|
||||
"biomarker": {
|
||||
"manage": "Biomarcatori",
|
||||
"manageTitle": "Biomarcatori",
|
||||
"manageSheetDescription": "Definisci i marcatori che segui. Ognuno si imposta una volta con la sua unità e l'intervallo di riferimento.",
|
||||
"managerDescription": "Definisci un marcatore una volta — unità e intervallo valgono per ogni misurazione.",
|
||||
"define": "Definisci",
|
||||
"defineFirst": "Definisci il tuo primo biomarcatore",
|
||||
@@ -6612,7 +6652,12 @@
|
||||
"illness": {
|
||||
"title": "Diario delle malattie",
|
||||
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
|
||||
"disclaimer": "Un diario personale, non un dispositivo medico. Non diagnostica né prevede.",
|
||||
"customize": "Personalizza diario delle malattie",
|
||||
"settings": {
|
||||
"title": "Personalizza diario delle malattie",
|
||||
"description": "Gestisci la vista e l'ordine degli episodi.",
|
||||
"back": "Torna al diario delle malattie"
|
||||
},
|
||||
"newEpisode": "Nuovo episodio",
|
||||
"logDay": "Registra un giorno",
|
||||
"markRecovered": "Segna come guarito",
|
||||
|
||||
+50
-5
@@ -3697,6 +3697,10 @@
|
||||
"body": "Wstępne kroki masz już za sobą. Aby coś zmienić, otwórz ustawienia — wszystko, co skonfigurowałeś, da się tam edytować.",
|
||||
"cta": "Otwórz pulpit"
|
||||
},
|
||||
"disclaimer": {
|
||||
"acknowledge": "Rozumiem: HealthLog to prywatne narzędzie do śledzenia, a nie ocena kliniczna ani diagnoza. Gdy się niepokoję, rozmawiam z lekarzem, a nie z tą aplikacją.",
|
||||
"learnMore": "Dowiedz się więcej"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Witaj w HealthLog",
|
||||
"carouselLabel": "Karuzela powitalna",
|
||||
@@ -6379,13 +6383,26 @@
|
||||
"until": "Do {date}",
|
||||
"selfPlanned": "Zaplanowane samodzielnie",
|
||||
"captureValue": "Zapisz wartość",
|
||||
"measureNow": "Zmierz teraz",
|
||||
"nextDueLabel": "Następny termin:",
|
||||
"lastDoneLabel": "Ostatnio wykonano:",
|
||||
"customize": "Dostosuj profilaktykę",
|
||||
"adherence": {
|
||||
"label": "W interwale",
|
||||
"onTrack": "Zgodnie z planem",
|
||||
"due": "Termin teraz"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Dostosuj profilaktykę",
|
||||
"description": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami.",
|
||||
"back": "Powrót do profilaktyki"
|
||||
},
|
||||
"capture": {
|
||||
"title": "Zapisz pomiar",
|
||||
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
|
||||
},
|
||||
"manage": {
|
||||
"menuItem": "Dostosuj",
|
||||
"title": "Dostosuj przypomnienia",
|
||||
"title": "Zarządzaj przypomnieniami",
|
||||
"description": "Włącz lub wyłącz poszczególne przypomnienia.",
|
||||
"empty": "Brak przypomnień do dostosowania."
|
||||
},
|
||||
@@ -6394,6 +6411,17 @@
|
||||
"openLink": "Otwórz profilaktykę"
|
||||
}
|
||||
},
|
||||
"moduleList": {
|
||||
"viewToggleLabel": "Widok",
|
||||
"viewCards": "Widok kart",
|
||||
"viewList": "Widok listy",
|
||||
"viewHeading": "Widok",
|
||||
"viewDescription": "Wybierz, czy pozycje mają być pokazywane jako karty czy jako zwarta lista.",
|
||||
"reorder": {
|
||||
"heading": "Kolejność",
|
||||
"empty": "Brak pozycji do uporządkowania."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
"subtitle": "Badania krwi i biomarkery",
|
||||
@@ -6409,6 +6437,20 @@
|
||||
"deletedToast": "Wynik usunięty",
|
||||
"deleteError": "Nie udało się usunąć wyniku",
|
||||
"loadError": "Nie udało się wczytać wyników",
|
||||
"customize": "Dostosuj wyniki badań",
|
||||
"settings": {
|
||||
"title": "Dostosuj wyniki badań",
|
||||
"description": "Zarządzaj widokiem, sortowaniem i biomarkerami.",
|
||||
"back": "Powrót do wyników badań"
|
||||
},
|
||||
"sort": {
|
||||
"heading": "Sortowanie",
|
||||
"label": "Kolejność wartości",
|
||||
"recentDesc": "Najnowszy pomiar najpierw",
|
||||
"recentAsc": "Najstarszy pomiar najpierw",
|
||||
"manual": "Sortuj ręcznie",
|
||||
"hint": "Przy sortowaniu ręcznym możesz przeciągnąć biomarkery poniżej we własnej kolejności."
|
||||
},
|
||||
"range": {
|
||||
"inRange": "W normie",
|
||||
"below": "Poniżej normy",
|
||||
@@ -6456,9 +6498,7 @@
|
||||
}
|
||||
},
|
||||
"biomarker": {
|
||||
"manage": "Biomarkery",
|
||||
"manageTitle": "Biomarkery",
|
||||
"manageSheetDescription": "Zdefiniuj śledzone markery. Każdy ustawiasz raz wraz z jednostką i zakresem referencyjnym.",
|
||||
"managerDescription": "Zdefiniuj marker raz — jednostka i zakres są używane dla każdego pomiaru.",
|
||||
"define": "Zdefiniuj",
|
||||
"defineFirst": "Zdefiniuj pierwszy biomarker",
|
||||
@@ -6612,7 +6652,12 @@
|
||||
"illness": {
|
||||
"title": "Dziennik chorób",
|
||||
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
|
||||
"disclaimer": "Osobisty dziennik — nie wyrób medyczny. Nie diagnozuje ani nie przewiduje.",
|
||||
"customize": "Dostosuj dziennik chorób",
|
||||
"settings": {
|
||||
"title": "Dostosuj dziennik chorób",
|
||||
"description": "Zarządzaj widokiem i kolejnością epizodów.",
|
||||
"back": "Powrót do dziennika chorób"
|
||||
},
|
||||
"newEpisode": "Nowy epizod",
|
||||
"logDay": "Zapisz dzień",
|
||||
"markRecovered": "Oznacz jako wyleczone",
|
||||
|
||||
+22
-41
@@ -1,20 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Loader2, MoreVertical, Plus } from "lucide-react";
|
||||
import { Loader2, Plus, Wrench } from "lucide-react";
|
||||
|
||||
import { BiomarkerManager } from "@/components/labs/biomarker-manager";
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -31,7 +25,6 @@ export default function LabsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
// Sticky-footer slot element for the add-result sheet (filled by the
|
||||
// ResponsiveSheet `footer` ref so the form portals its action row there).
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -42,7 +35,7 @@ export default function LabsPage() {
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen || manageOpen,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,34 +69,31 @@ export default function LabsPage() {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="labs" />
|
||||
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
|
||||
point, left of the primary Add and linking to the Labs settings
|
||||
page (view, sort order, biomarker CRUD + reorder). Mirrors the
|
||||
medication page header's wrench glyph + slot + 44px tap floor. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/labs"
|
||||
aria-label={t("labs.customize")}
|
||||
title={t("labs.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.addResult")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
{/* Secondary actions fold into one overflow kebab beside the primary
|
||||
Add, mirroring the medication-card header (one cluster, not a
|
||||
two-button row). */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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)}>
|
||||
<FlaskConical className="mr-2 h-4 w-4" />
|
||||
{t("labs.biomarker.manage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,15 +116,6 @@ export default function LabsPage() {
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
title={t("labs.biomarker.manageTitle")}
|
||||
description={t("labs.biomarker.manageSheetDescription")}
|
||||
>
|
||||
<BiomarkerManager />
|
||||
</ResponsiveSheet>
|
||||
|
||||
<LabList onAddFirst={() => setDialogOpen(true)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
|
||||
import { IllnessSettings } from "@/components/illness/illness-settings";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Illness module's own settings page. Dedicated
|
||||
* static route (wins over `/settings/[section]`); reached from the wrench
|
||||
* beside the Illness page's "hinzufügen" button.
|
||||
*/
|
||||
export default function IllnessSettingsPage() {
|
||||
const { isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleSettingsFrame
|
||||
title={t("illness.settings.title")}
|
||||
description={t("illness.settings.description")}
|
||||
backHref="/illness"
|
||||
backLabelKey="illness.settings.back"
|
||||
>
|
||||
<IllnessSettings />
|
||||
</ModuleSettingsFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
|
||||
import { LabsSettings } from "@/components/labs/labs-settings";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page.
|
||||
* Dedicated static route (wins over `/settings/[section]`); reached from the
|
||||
* wrench beside the Labs page's "hinzufügen" button.
|
||||
*/
|
||||
export default function LabsSettingsPage() {
|
||||
const { isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleSettingsFrame
|
||||
title={t("labs.settings.title")}
|
||||
description={t("labs.settings.description")}
|
||||
backHref="/labs"
|
||||
backLabelKey="labs.settings.back"
|
||||
>
|
||||
<LabsSettings />
|
||||
</ModuleSettingsFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame";
|
||||
import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page.
|
||||
*
|
||||
* A dedicated static route that wins over the dynamic `/settings/[section]`
|
||||
* router (static segments take precedence in Next.js), so it adds a
|
||||
* per-module settings surface without touching the parallel-phase-owned
|
||||
* `section-slugs` registry or `settings-shell`. Reached from the wrench
|
||||
* beside the Vorsorge page's "hinzufügen" button.
|
||||
*/
|
||||
export default function VorsorgeSettingsPage() {
|
||||
const { isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModuleSettingsFrame
|
||||
title={t("measurementReminders.settings.title")}
|
||||
description={t("measurementReminders.settings.description")}
|
||||
backHref="/vorsorge"
|
||||
backLabelKey="measurementReminders.settings.back"
|
||||
>
|
||||
<VorsorgeSettings />
|
||||
</ModuleSettingsFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Illness module's own settings page body.
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user reorder conditions/episodes + pick the card-vs-list page view. Order
|
||||
* + view persist client-side via `useModuleListPrefs("illness")`.
|
||||
*/
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
type ReorderItem,
|
||||
} from "@/components/module-list/module-order-editor";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { useIllnessEpisodes } from "./use-illness";
|
||||
|
||||
export function IllnessSettings() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const { prefs, setView, setOrder } = useModuleListPrefs("illness");
|
||||
|
||||
const ordered = applyOrder(episodes ?? [], prefs.order, (e) => e.id);
|
||||
const reorderItems: ReorderItem[] = ordered.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.label,
|
||||
secondary: `${t(`illness.type.${e.type}`)} · ${
|
||||
e.resolvedAt
|
||||
? t("illness.status.recovered")
|
||||
: e.lifecycle === "CHRONIC_ONGOING"
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
} · ${fmt.dateShort(new Date(e.onsetAt))}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.viewHeading")}
|
||||
</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Stethoscope } from "lucide-react";
|
||||
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -32,6 +32,7 @@ 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 { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
@@ -58,6 +59,7 @@ interface EpisodeCardProps {
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
view: "cards" | "list";
|
||||
}
|
||||
|
||||
function EpisodeCard({
|
||||
@@ -67,6 +69,7 @@ function EpisodeCard({
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
view,
|
||||
}: EpisodeCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -74,6 +77,58 @@ function EpisodeCard({
|
||||
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
|
||||
const flareCount = flares?.length ?? 0;
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list row: a single divided line per episode
|
||||
// (label + status chips + onset; tap navigates to the detail surface).
|
||||
if (view === "list") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Link
|
||||
href={`/illness/${episode.id}`}
|
||||
className="block truncate text-sm font-medium hover:underline focus-visible:underline"
|
||||
>
|
||||
{episode.label}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<Badge variant="secondary">
|
||||
{t(`illness.type.${episode.type}`)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{active
|
||||
? isChronic
|
||||
? t("illness.status.ongoing")
|
||||
: t("illness.status.active")
|
||||
: t("illness.status.recovered")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{fmt.dateShort(new Date(episode.onsetAt))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="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}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3 md:gap-3">
|
||||
<CardHeader className="pb-0">
|
||||
@@ -170,6 +225,8 @@ function EpisodeGroup({
|
||||
onEdit,
|
||||
onResolve,
|
||||
resolving,
|
||||
view,
|
||||
collapsible = false,
|
||||
}: {
|
||||
title: string;
|
||||
parents: IllnessEpisodeDTO[];
|
||||
@@ -178,26 +235,71 @@ function EpisodeGroup({
|
||||
onEdit: (episode: IllnessEpisodeDTO) => void;
|
||||
onResolve: (id: string) => void;
|
||||
resolving: boolean;
|
||||
view: "cards" | "list";
|
||||
/**
|
||||
* v1.18.6 (MOD-05) — the resolved group collapses by default behind a
|
||||
* disclosure trigger so a long recovered history never pushes the active
|
||||
* conditions below the fold.
|
||||
*/
|
||||
collapsible?: boolean;
|
||||
}) {
|
||||
// Hooks must run unconditionally; bail on the empty render afterwards.
|
||||
const [open, setOpen] = useState(false);
|
||||
if (parents.length === 0) return null;
|
||||
|
||||
const grid = (
|
||||
<div
|
||||
className={cn(
|
||||
view === "list" ? "space-y-2" : "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}
|
||||
view={view}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium uppercase tracking-wide transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform motion-reduce:transition-none",
|
||||
open ? "rotate-0" : "-rotate-90",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
<span className="text-muted-foreground/70 normal-case">
|
||||
({parents.length})
|
||||
</span>
|
||||
</button>
|
||||
{open ? grid : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{grid}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -206,6 +308,7 @@ export function IllnessView() {
|
||||
const { t } = useTranslations();
|
||||
const { data: episodes, isLoading } = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
const { prefs } = useModuleListPrefs("illness");
|
||||
|
||||
const today = todayLocal();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
@@ -248,8 +351,14 @@ export function IllnessView() {
|
||||
}
|
||||
}
|
||||
|
||||
return { activeParents, resolvedParents, childrenByParent };
|
||||
}, [episodes]);
|
||||
return {
|
||||
// v1.18.6 (MOD-03) — honour the user's persisted manual order within
|
||||
// each status group (ids absent from the order sort after the block).
|
||||
activeParents: applyOrder(activeParents, prefs.order, (e) => e.id),
|
||||
resolvedParents: applyOrder(resolvedParents, prefs.order, (e) => e.id),
|
||||
childrenByParent,
|
||||
};
|
||||
}, [episodes, prefs.order]);
|
||||
|
||||
const hasEpisodes = (episodes?.length ?? 0) > 0;
|
||||
|
||||
@@ -269,17 +378,37 @@ export function IllnessView() {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="illness" />
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/illness"
|
||||
aria-label={t("illness.customize")}
|
||||
title={t("illness.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
|
||||
other module, not the bespoke "neue Episode". */}
|
||||
<Button
|
||||
onClick={() => setNewOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("illness.newEpisode")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
|
||||
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
|
||||
the one-time acknowledgment now lives at onboarding and the legal
|
||||
text stays reachable on the public privacy page. */}
|
||||
|
||||
{isLoading ? (
|
||||
<div className={cn("grid gap-4 sm:grid-cols-2")}>
|
||||
@@ -297,6 +426,7 @@ export function IllnessView() {
|
||||
parents={grouped.activeParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
view={prefs.view}
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
@@ -306,6 +436,8 @@ export function IllnessView() {
|
||||
parents={grouped.resolvedParents}
|
||||
childrenByParent={grouped.childrenByParent}
|
||||
resolving={resolve.isPending}
|
||||
view={prefs.view}
|
||||
collapsible
|
||||
onLogDay={(id) => setLogEpisodeId(id)}
|
||||
onEdit={(e) => setEditEpisode(e)}
|
||||
onResolve={(id) => resolve.mutate(id)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
|
||||
import { LabTrendSparkline } from "./lab-trend-sparkline";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
@@ -79,6 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
|
||||
|
||||
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
const { t } = useTranslations();
|
||||
const { prefs } = useModuleListPrefs("labs");
|
||||
|
||||
const listKey = queryKeys.labResultsList({
|
||||
biomarkerId: undefined,
|
||||
@@ -96,10 +98,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
|
||||
});
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupReadings(data?.results ?? []),
|
||||
[data?.results],
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const base = groupReadings(data?.results ?? []);
|
||||
// v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings`
|
||||
// already returns most-recent-first; `recentAsc` reverses, and `manual`
|
||||
// applies the persisted biomarker order (legacy un-linked groups, which
|
||||
// carry no biomarkerId, sort after the ordered block).
|
||||
if (prefs.sortDir === "recentAsc") return [...base].reverse();
|
||||
if (prefs.sortDir === "manual") {
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.results, prefs.sortDir, prefs.order]);
|
||||
|
||||
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
|
||||
// calm "showing latest N of M" hint when the cap truncates so the count is
|
||||
@@ -149,6 +159,65 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list view: one bordered card holding tight
|
||||
// divided rows instead of a card per biomarker. The card view stays the
|
||||
// default; this is the denser alternative the settings toggle selects.
|
||||
if (prefs.view === "list") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{truncated ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.showingLatestOf", { shown, total })}
|
||||
</p>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{groups.map((group) => {
|
||||
const row = (
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{group.analyte}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabValue(group.latest.value)}{" "}
|
||||
{group.latest.unit}
|
||||
</span>
|
||||
<span>{formatDate(group.latest.takenAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
{group.biomarkerId ? (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return group.biomarkerId ? (
|
||||
<Link
|
||||
key={group.key}
|
||||
href={`/labs/${group.biomarkerId}`}
|
||||
className="hover:bg-muted/40 block transition-colors"
|
||||
>
|
||||
{row}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={group.key}>{row}</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{truncated ? (
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page body.
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user:
|
||||
* - pick the card-vs-list page view (MOD-03),
|
||||
* - choose the sort order (by most-recent reading, descending / ascending,
|
||||
* or manual) — MOD-04,
|
||||
* - reorder biomarkers when sort = manual (MOD-03/04),
|
||||
* - and create / edit / delete biomarkers via the existing
|
||||
* `BiomarkerManager` (MOD-04).
|
||||
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
type ReorderItem,
|
||||
} from "@/components/module-list/module-order-editor";
|
||||
import {
|
||||
applyOrder,
|
||||
useModuleListPrefs,
|
||||
type ModuleSortDir,
|
||||
} from "@/lib/module-list-prefs";
|
||||
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
});
|
||||
const markers: BiomarkerDto[] = data?.biomarkers ?? [];
|
||||
|
||||
const ordered = applyOrder(markers, prefs.order, (m) => m.id);
|
||||
const reorderItems: ReorderItem[] = ordered.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
|
||||
}));
|
||||
|
||||
const manual = prefs.sortDir === "manual";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.viewHeading")}
|
||||
</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">{t("labs.sort.heading")}</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="labs-sort">{t("labs.sort.label")}</Label>
|
||||
<NativeSelect
|
||||
id="labs-sort"
|
||||
value={prefs.sortDir}
|
||||
onChange={(e) => setSortDir(e.target.value as ModuleSortDir)}
|
||||
>
|
||||
<option value="recentDesc">{t("labs.sort.recentDesc")}</option>
|
||||
<option value="recentAsc">{t("labs.sort.recentAsc")}</option>
|
||||
<option value="manual">{t("labs.sort.manual")}</option>
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.sort.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{manual ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("labs.biomarker.manageTitle")}
|
||||
</h2>
|
||||
<BiomarkerManager />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,9 +73,10 @@ 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).
|
||||
it("renders a 'Measure now' action for a measurement-linked reminder", () => {
|
||||
// v1.18.2 — a typed reminder's primary action opens the value-entry form.
|
||||
// v1.18.6 (MOD-06) — surfaced as a green "Measure now" button (a
|
||||
// measurement is not an intake), not a silent checkmark.
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "linked",
|
||||
label: "Measure blood pressure",
|
||||
@@ -95,7 +96,7 @@ describe("<VorsorgeSection> loading + empty", () => {
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Log value");
|
||||
expect(html).toContain("Measure now");
|
||||
expect(html).not.toContain(">Done<");
|
||||
});
|
||||
|
||||
|
||||
@@ -29,9 +29,18 @@
|
||||
* card so the card carries a single med-style kebab).
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
@@ -59,12 +68,11 @@ 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 { Progress } from "@/components/ui/progress";
|
||||
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,
|
||||
@@ -204,15 +212,15 @@ export function VorsorgeSection({
|
||||
const { data: reminders, isLoading } = useMeasurementReminders(enabled);
|
||||
const { create, update, remove, satisfy } =
|
||||
useMeasurementReminderMutations();
|
||||
// v1.18.6 (MOD-03) — page view (cards/list) + manual order persist
|
||||
// client-side; the settings page writes them, the page reads them.
|
||||
const { prefs } = useModuleListPrefs("vorsorge");
|
||||
|
||||
// `null` = sheet closed; "new" = create; a reminder = edit pre-filled.
|
||||
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
|
||||
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);
|
||||
@@ -328,7 +336,8 @@ export function VorsorgeSection({
|
||||
const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM;
|
||||
const cadenceRrule = form.cadenceChoice === CADENCE_RRULE;
|
||||
|
||||
// The shared primary add-entry affordance — identical in both variants.
|
||||
// v1.18.6 (MOD-02) — the primary add affordance reads "hinzufügen" like
|
||||
// every other module's add button.
|
||||
const addButton = (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -336,32 +345,29 @@ export function VorsorgeSection({
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurementReminders.addButton")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
// v1.18.2 — wrench/customize affordance, mirroring the labs page: a
|
||||
// ghost icon button beside Add opening the manage sheet.
|
||||
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
|
||||
// reorder, per-reminder enable toggles), left of the Add button — the
|
||||
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
|
||||
// gone (MOD-07); its toggles moved to the settings page.
|
||||
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>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/vorsorge"
|
||||
aria-label={t("measurementReminders.customize")}
|
||||
title={t("measurementReminders.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -382,8 +388,8 @@ export function VorsorgeSection({
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="vorsorge" />
|
||||
{addButton}
|
||||
{wrenchButton}
|
||||
{addButton}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -634,42 +640,6 @@ export function VorsorgeSection({
|
||||
)}
|
||||
</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="grid gap-4 sm:grid-cols-2"
|
||||
@@ -694,37 +664,68 @@ export function VorsorgeSection({
|
||||
description={t("measurementReminders.empty.description")}
|
||||
action={
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("measurementReminders.addButton")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!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
|
||||
className={
|
||||
prefs.view === "list"
|
||||
? "list-none space-y-2 p-0"
|
||||
: "grid list-none gap-4 p-0 sm:grid-cols-2"
|
||||
}
|
||||
>
|
||||
{applyOrder(reminders ?? [], prefs.order, (r) => r.id).map(
|
||||
(reminder) => (
|
||||
<li key={reminder.id} className="contents">
|
||||
<VorsorgeCard
|
||||
reminder={reminder}
|
||||
now={now}
|
||||
view={prefs.view}
|
||||
onPrimaryAction={() => onPrimaryAction(reminder)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
busy={
|
||||
satisfy.isPending || remove.isPending || update.isPending
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.6 (MOD-06) — progress through the current interval toward the next
|
||||
* due date, as a 0–100 percentage. Honest "are we on track" gamification
|
||||
* that reuses the medication card's `Progress` building block without
|
||||
* fabricating a streak the reminder data cannot support: 100 % (or beyond)
|
||||
* means due/overdue now, lower means time still remains in the window.
|
||||
* Returns null when there is no interval to measure against (RRULE / unset).
|
||||
*/
|
||||
function intervalProgress(
|
||||
reminder: MeasurementReminder,
|
||||
now: number,
|
||||
): number | null {
|
||||
if (reminder.intervalDays == null || reminder.nextDueAt == null) return null;
|
||||
const dueMs = new Date(reminder.nextDueAt).getTime();
|
||||
if (Number.isNaN(dueMs)) return null;
|
||||
const windowMs = reminder.intervalDays * DAY_MS;
|
||||
const startMs = dueMs - windowMs;
|
||||
const elapsed = now - startMs;
|
||||
return Math.max(0, Math.min(100, Math.round((elapsed / windowMs) * 100)));
|
||||
}
|
||||
|
||||
function VorsorgeCard({
|
||||
reminder,
|
||||
now,
|
||||
view,
|
||||
onPrimaryAction,
|
||||
onRemove,
|
||||
onEdit,
|
||||
@@ -732,6 +733,7 @@ function VorsorgeCard({
|
||||
}: {
|
||||
reminder: MeasurementReminder;
|
||||
now: number;
|
||||
view: "cards" | "list";
|
||||
onPrimaryAction: () => void;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
@@ -751,6 +753,12 @@ function VorsorgeCard({
|
||||
: "";
|
||||
const isCoach = reminder.origin === "COACH";
|
||||
const isLinked = reminder.measurementType != null;
|
||||
// Due now / overdue ⇒ the action button takes the green "do it now" tone,
|
||||
// analogous to the medication card's due affordance. The CARD stays neutral
|
||||
// (no tint) per the project rule — only the action button goes green.
|
||||
const isDue =
|
||||
due.key === "nextDue.today" || due.key === "overdueByDays";
|
||||
const progress = intervalProgress(reminder, now);
|
||||
|
||||
// Category-style header badge: the measurement label, or "self-planned"
|
||||
// for a free-text exam — mirroring the medication card's class badge.
|
||||
@@ -758,21 +766,22 @@ function VorsorgeCard({
|
||||
? 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;
|
||||
// v1.18.6 (MOD-06) — the cadence renders as a chip beside the item name
|
||||
// (the med-card cadence-chip convention), alongside the provenance /
|
||||
// disabled state badges.
|
||||
const stateBadges = (
|
||||
<>
|
||||
{cadence && <Badge variant="secondary">{cadence}</Badge>}
|
||||
{isCoach && (
|
||||
<Badge variant="outline">{t("measurementReminders.originCoach")}</Badge>
|
||||
)}
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Single med-style kebab: Edit + Delete. The Delete item is the shared
|
||||
// confirm-on-delete control rendered as a full-width menu row.
|
||||
@@ -806,95 +815,186 @@ function VorsorgeCard({
|
||||
</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>
|
||||
// v1.18.6 (MOD-06) — the green "Jetzt messen" / mark-done action. Green only
|
||||
// on the action button (never the card surface). When not due it stays the
|
||||
// calm default tone so the green reads as "now is the time".
|
||||
const primaryButton = (
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
"min-h-11 w-full",
|
||||
isDue && "bg-success text-white hover:bg-success/90",
|
||||
)}
|
||||
onClick={onPrimaryAction}
|
||||
disabled={busy}
|
||||
>
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4" />
|
||||
{t("measurementReminders.measureNow")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("measurementReminders.markDone")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const deleteDialog = (
|
||||
<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>
|
||||
);
|
||||
|
||||
// v1.18.6 (MOD-03) — compact list row variant.
|
||||
if (view === "list") {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span className="truncate font-medium">
|
||||
{resolveReminderLabel(reminder, t)}
|
||||
</span>
|
||||
{cadence && <Badge variant="secondary">{cadence}</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<Badge variant="outline">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{categoryLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"min-h-9",
|
||||
isDue && "bg-success text-white hover:bg-success/90",
|
||||
)}
|
||||
onClick={onPrimaryAction}
|
||||
disabled={busy}
|
||||
>
|
||||
{isLinked
|
||||
? t("measurementReminders.measureNow")
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
{headerActions}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{deleteDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Next/last block in the med-card grammar, but with MEASUREMENT wording
|
||||
// (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The
|
||||
// "next" value carries the discreet neutral due badge; the "last" value is
|
||||
// the last satisfied date.
|
||||
const lastValue = reminder.lastSatisfiedAt
|
||||
? fmt.date(new Date(reminder.lastSatisfiedAt))
|
||||
: null;
|
||||
const nextLastSlot = (
|
||||
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("measurementReminders.nextDueLabel")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
<Badge variant="secondary">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
{lastValue && (
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("measurementReminders.lastDoneLabel")}
|
||||
</span>
|
||||
<span className="text-foreground text-right">{lastValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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} />
|
||||
{/* NEUTRAL card — no status-driven colour. Due state reads only through
|
||||
the discreet badge + the green action button below (MOD-06). 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">
|
||||
{nextLastSlot}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{/* v1.18.6 (MOD-06) — light gamification: progress through the
|
||||
current interval toward next-due, reusing the medication card's
|
||||
Progress building block. Honest "on track / due" signal without
|
||||
a fabricated streak; hidden for RRULE / unscheduled reminders. */}
|
||||
{progress != null && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs">
|
||||
<span>{t("measurementReminders.adherence.label")}</span>
|
||||
<span className="tabular-nums">
|
||||
{isDue
|
||||
? t("measurementReminders.adherence.due")
|
||||
: t("measurementReminders.adherence.onTrack")}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
aria-label={t("measurementReminders.adherence.label")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
|
||||
<div className="mt-auto pt-0">{primaryButton}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page body.
|
||||
*
|
||||
* Reached from the wrench beside the page's "hinzufügen" button. Lets the
|
||||
* user reorder reminders + pick the card-vs-list page view, and re-homes the
|
||||
* per-reminder enable/disable toggles that used to live in the page's
|
||||
* "Anpassen" sheet. Order + view persist client-side via
|
||||
* `useModuleListPrefs("vorsorge")`; the toggles persist through the existing
|
||||
* reminder PATCH route.
|
||||
*/
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
import {
|
||||
ModuleOrderEditor,
|
||||
type ReorderItem,
|
||||
} from "@/components/module-list/module-order-editor";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
type MeasurementReminder,
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
|
||||
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 VorsorgeSettings() {
|
||||
const { t } = useTranslations();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders(true);
|
||||
const { update } = useMeasurementReminderMutations();
|
||||
const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge");
|
||||
|
||||
const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id);
|
||||
const reorderItems: ReorderItem[] = ordered.map((r) => ({
|
||||
id: r.id,
|
||||
name: resolveLabel(r, t),
|
||||
secondary: r.measurementType
|
||||
? t(`measurementReminders.types.${r.measurementType}`)
|
||||
: t("measurementReminders.selfPlanned"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">{t("moduleList.viewHeading")}</h2>
|
||||
<ModuleViewToggle view={prefs.view} onChange={setView} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.viewDescription")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("moduleList.reorder.heading")}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("measurementReminders.manage.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.manage.description")}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (reminders?.length ?? 0) === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.manage.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{ordered.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm">
|
||||
{resolveLabel(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")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — generic, controlled reorder list shared by the
|
||||
* Vorsorge / Illness / Labs settings pages.
|
||||
*
|
||||
* A single section (no active/inactive split — the secondary modules have a
|
||||
* flat list) with the same vocabulary as the medication order editor: an
|
||||
* `@dnd-kit` drag handle (pointer + keyboard sensors) plus per-row up/down
|
||||
* arrow buttons as the primary keyboard surface. Unlike the medication
|
||||
* editor it is fully controlled — there is no Save/Cancel draft state
|
||||
* because the host persists each move immediately to the localStorage order
|
||||
* (the choice is presentational, not a server write). The swap-and-renumber
|
||||
* logic is the shared `reorderById` helper so the order semantics match the
|
||||
* dashboard / insights / medications surfaces exactly.
|
||||
*/
|
||||
|
||||
export interface ReorderItem {
|
||||
id: string;
|
||||
/** Primary label (bold). */
|
||||
name: string;
|
||||
/** Optional secondary line (muted). */
|
||||
secondary?: string;
|
||||
}
|
||||
|
||||
interface ModuleOrderEditorProps {
|
||||
/** The list in its current effective order. */
|
||||
items: ReorderItem[];
|
||||
/** Persist the full id order after every move. */
|
||||
onChange: (order: string[]) => void;
|
||||
}
|
||||
|
||||
export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
|
||||
const { t } = useTranslations();
|
||||
const dragHintId = useId();
|
||||
const ids = items.map((i) => i.id);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const labels = {
|
||||
moveUp: t("dashboard.moveUp"),
|
||||
moveDown: t("dashboard.moveDown"),
|
||||
dragHandle: t("dashboard.dragHandle"),
|
||||
};
|
||||
|
||||
function applyReorder(fromId: string, toId: string) {
|
||||
onChange(
|
||||
reorderById(
|
||||
ids.map((id, order) => ({ id, order })),
|
||||
fromId,
|
||||
toId,
|
||||
).map((r) => r.id),
|
||||
);
|
||||
}
|
||||
|
||||
function move(id: string, delta: -1 | 1) {
|
||||
const idx = ids.indexOf(id);
|
||||
const targetIdx = idx + delta;
|
||||
if (idx < 0 || targetIdx < 0 || targetIdx >= ids.length) return;
|
||||
applyReorder(id, ids[targetIdx]);
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
applyReorder(String(active.id), String(over.id));
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("moduleList.reorder.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="module-order-editor">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => (
|
||||
<SortableRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
total={items.length}
|
||||
dragHintId={dragHintId}
|
||||
labels={labels}
|
||||
onMove={move}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<p id={dragHintId} className="text-muted-foreground sr-only">
|
||||
{t("dashboard.dragHandleHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableRowProps {
|
||||
item: ReorderItem;
|
||||
index: number;
|
||||
total: number;
|
||||
dragHintId: string;
|
||||
labels: { moveUp: string; moveDown: string; dragHandle: string };
|
||||
onMove: (id: string, delta: -1 | 1) => void;
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
item,
|
||||
index,
|
||||
total,
|
||||
dragHintId,
|
||||
labels,
|
||||
onMove,
|
||||
}: SortableRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: prefersReducedMotion() ? "none" : transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="module-reorder-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle} — ${item.name}`}
|
||||
aria-describedby={dragHintId}
|
||||
title={labels.dragHandle}
|
||||
data-slot="module-drag-handle"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative -m-1 inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing motion-reduce:transition-none"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 text-sm">
|
||||
<span className="block truncate font-medium" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.secondary ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{item.secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(item.id, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={`${labels.moveUp} — ${item.name}`}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(item.id, 1)}
|
||||
disabled={index === total - 1}
|
||||
aria-label={`${labels.moveDown} — ${item.name}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — heading frame for the per-module settings pages
|
||||
* (Vorsorge / Illness / Labs).
|
||||
*
|
||||
* FRAME-ADOPTION SEAM: a parallel phase is introducing a shared
|
||||
* `SettingsSectionFrame` primitive under `src/components/settings/*` (which
|
||||
* this phase must not edit). Until that lands, this renders the canonical
|
||||
* settings heading markup verbatim — `h1.text-2xl.font-bold.tracking-tight`
|
||||
* + `p.text-muted-foreground.text-sm` — so the three new pages already
|
||||
* conform to the standard and can be swapped to the shared frame at merge by
|
||||
* replacing this single wrapper. The back-link reuses the existing
|
||||
* `SettingsHubBackLink` (read-only import) so the pages have a consistent
|
||||
* "return to the hub" affordance even though they are not listed in the
|
||||
* settings-shell sidebar.
|
||||
*/
|
||||
export function ModuleSettingsFrame({
|
||||
title,
|
||||
description,
|
||||
backHref,
|
||||
backLabelKey,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Where the "← back" link returns to — the module's own page. */
|
||||
backHref: string;
|
||||
backLabelKey: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<SettingsHubBackLink href={backHref} labelKey={backLabelKey} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid, List as ListIcon } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModuleListView } from "@/lib/module-list-prefs";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the
|
||||
* Vorsorge / Illness / Labs settings pages. Mirrors the medication module's
|
||||
* `MedicationViewToggle` grammar (two ghost segments, the active one filled,
|
||||
* `aria-pressed` per segment) but switches between a card grid and a compact
|
||||
* list rather than cards-vs-table, so the label set is `moduleList.view*`.
|
||||
*/
|
||||
interface ModuleViewToggleProps {
|
||||
view: ModuleListView;
|
||||
onChange: (view: ModuleListView) => void;
|
||||
}
|
||||
|
||||
export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const segments = [
|
||||
{
|
||||
value: "cards" as const,
|
||||
label: t("moduleList.viewCards"),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
value: "list" as const,
|
||||
label: t("moduleList.viewList"),
|
||||
icon: ListIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("moduleList.viewToggleLabel")}
|
||||
className="border-border bg-background/30 inline-flex shrink-0 items-center rounded-md border p-0.5"
|
||||
>
|
||||
{segments.map(({ value, label, icon: Icon }) => {
|
||||
const active = view === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!active) onChange(value);
|
||||
}}
|
||||
aria-pressed={active}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
data-slot={`module-view-${value}`}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
|
||||
active
|
||||
? "bg-secondary text-secondary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyOrder, parseModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
|
||||
describe("module-list-prefs", () => {
|
||||
describe("parseModuleListPrefs", () => {
|
||||
it("returns defaults for null / malformed input", () => {
|
||||
expect(parseModuleListPrefs(null)).toEqual({
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
});
|
||||
expect(parseModuleListPrefs("{not json")).toEqual({
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
});
|
||||
});
|
||||
|
||||
it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
|
||||
const parsed = parseModuleListPrefs(
|
||||
JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
view: "cards",
|
||||
sortDir: "recentDesc",
|
||||
order: ["a", "b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips valid values", () => {
|
||||
const parsed = parseModuleListPrefs(
|
||||
JSON.stringify({ view: "list", sortDir: "manual", order: ["x"] }),
|
||||
);
|
||||
expect(parsed).toEqual({
|
||||
view: "list",
|
||||
sortDir: "manual",
|
||||
order: ["x"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOrder", () => {
|
||||
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
|
||||
const idOf = (i: { id: string }) => i.id;
|
||||
|
||||
it("returns the input order when no order is persisted", () => {
|
||||
expect(applyOrder(items, [], idOf)).toEqual(items);
|
||||
});
|
||||
|
||||
it("places ordered ids first, then the server-default tail", () => {
|
||||
const result = applyOrder(items, ["c", "a"], idOf);
|
||||
expect(result.map(idOf)).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("ignores ids in the order that are absent from the data", () => {
|
||||
const result = applyOrder(items, ["z", "b"], idOf);
|
||||
expect(result.map(idOf)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 (W8 / MOD-03) — client-side per-module list preferences for the
|
||||
* three secondary modules (Vorsorge, Illness, Labs).
|
||||
*
|
||||
* These modules do not carry a server-side list-layout column the way
|
||||
* Medications does (`/api/medications/layout`). Rather than mint three new
|
||||
* Prisma columns + routes — which a parallel backend phase owns — the
|
||||
* reorder + view choice live in `localStorage`, keyed per module. The choice
|
||||
* is presentational only: it reorders / reshapes what the page already
|
||||
* fetched and never feeds a server read, so a missing or stale blob degrades
|
||||
* to the server's own default order.
|
||||
*
|
||||
* Shape per module (one localStorage key each):
|
||||
* { view: "cards" | "list", order: string[] }
|
||||
* `order` is an id allow-list; ids absent from the live data are ignored and
|
||||
* ids present in the data but absent from `order` sort after the ordered
|
||||
* block (the server default order is preserved for the tail).
|
||||
*/
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
|
||||
export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
|
||||
|
||||
export type ModuleListKey = "vorsorge" | "illness" | "labs";
|
||||
|
||||
/**
|
||||
* Sort choice for the Labs surface (MOD-04): order biomarker groups by the
|
||||
* recency of their most-recent reading. `manual` defers to the persisted
|
||||
* `order` array instead.
|
||||
*/
|
||||
export const MODULE_SORT_DIRS = ["recentDesc", "recentAsc", "manual"] as const;
|
||||
export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
|
||||
|
||||
export interface ModuleListPrefs {
|
||||
view: ModuleListView;
|
||||
order: string[];
|
||||
/** Only consumed by the Labs surface; defaults to most-recent-first. */
|
||||
sortDir: ModuleSortDir;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: ModuleListPrefs = {
|
||||
view: "cards",
|
||||
order: [],
|
||||
sortDir: "recentDesc",
|
||||
};
|
||||
|
||||
function storageKey(module: ModuleListKey): string {
|
||||
return `healthlog:module-list-prefs:${module}`;
|
||||
}
|
||||
|
||||
function isView(value: unknown): value is ModuleListView {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(MODULE_LIST_VIEWS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isSortDir(value: unknown): value is ModuleSortDir {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(MODULE_SORT_DIRS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseModuleListPrefs(raw: string | null): ModuleListPrefs {
|
||||
if (!raw) return DEFAULT_PREFS;
|
||||
try {
|
||||
const blob = JSON.parse(raw) as Record<string, unknown>;
|
||||
const view = isView(blob.view) ? blob.view : DEFAULT_PREFS.view;
|
||||
const order = Array.isArray(blob.order)
|
||||
? blob.order.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
const sortDir = isSortDir(blob.sortDir)
|
||||
? blob.sortDir
|
||||
: DEFAULT_PREFS.sortDir;
|
||||
return { view, order, sortDir };
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a persisted id order to a live list. Items whose id appears in
|
||||
* `order` come first in that order; everything else keeps the input order
|
||||
* (the server default) after the ordered block. Ids in `order` but absent
|
||||
* from the data are silently dropped.
|
||||
*/
|
||||
export function applyOrder<T>(
|
||||
items: readonly T[],
|
||||
order: readonly string[],
|
||||
idOf: (item: T) => string,
|
||||
): T[] {
|
||||
if (order.length === 0) return [...items];
|
||||
const rank = new Map(order.map((id, i) => [id, i]));
|
||||
const ordered: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const item of items) {
|
||||
if (rank.has(idOf(item))) ordered.push(item);
|
||||
else rest.push(item);
|
||||
}
|
||||
ordered.sort((a, b) => rank.get(idOf(a))! - rank.get(idOf(b))!);
|
||||
return [...ordered, ...rest];
|
||||
}
|
||||
|
||||
// A module-scoped event so every mounted hook for the same module repaints
|
||||
// when one writer flushes a change (the settings page + the module page can
|
||||
// both be alive across a client navigation without a full reload).
|
||||
const listeners = new Set<() => void>();
|
||||
function emit() {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read + write the per-module list preferences. SSR-safe via
|
||||
* `useSyncExternalStore` with a null server snapshot (parsed to defaults).
|
||||
*/
|
||||
export function useModuleListPrefs(module: ModuleListKey): {
|
||||
prefs: ModuleListPrefs;
|
||||
setView: (view: ModuleListView) => void;
|
||||
setOrder: (order: string[]) => void;
|
||||
setSortDir: (sortDir: ModuleSortDir) => void;
|
||||
} {
|
||||
const key = storageKey(module);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
listeners.add(onChange);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === null || e.key === key) onChange();
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(onChange);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
}
|
||||
};
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.localStorage.getItem(key);
|
||||
}, [key]);
|
||||
|
||||
const raw = useSyncExternalStore(subscribe, getSnapshot, () => null);
|
||||
const prefs = parseModuleListPrefs(raw);
|
||||
|
||||
const write = useCallback(
|
||||
(next: ModuleListPrefs) => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(key, JSON.stringify(next));
|
||||
emit();
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const setView = useCallback(
|
||||
(view: ModuleListView) =>
|
||||
write({ ...parseModuleListPrefs(getSnapshot()), view }),
|
||||
[write, getSnapshot],
|
||||
);
|
||||
const setOrder = useCallback(
|
||||
(order: string[]) =>
|
||||
write({ ...parseModuleListPrefs(getSnapshot()), order }),
|
||||
[write, getSnapshot],
|
||||
);
|
||||
const setSortDir = useCallback(
|
||||
(sortDir: ModuleSortDir) =>
|
||||
write({ ...parseModuleListPrefs(getSnapshot()), sortDir }),
|
||||
[write, getSnapshot],
|
||||
);
|
||||
|
||||
return { prefs, setView, setOrder, setSortDir };
|
||||
}
|
||||
Reference in new issue
Block a user