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:
Marc-André Bombeck committed 2026-06-18 21:36:58 +02:00
1 parent ae3091df9e
commit 267dcad13d
22 files changed
+1834 -273

No files matched your search

+50 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
+39
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+150 -18
View File
@@ -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)}
+73 -4
View File
@@ -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 ? (
+115
View File
@@ -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"]);
});
});
});
+181
View File
@@ -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 };
}