diff --git a/messages/de.json b/messages/de.json index 3dcc80763..32a97fae7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index a87949920..c0a330bde 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 99b6d7782..744070485 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 85cb525e9..93a0425fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 48507ed1d..5c3af1b83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index c60c651f4..e220a1d80 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 98196c102..9a4392835 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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(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() {
+ {/* 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. */} + - {/* Secondary actions fold into one overflow kebab beside the primary - Add, mirroring the medication-card header (one cluster, not a - two-button row). */} - - - - - - setManageOpen(true)}> - - {t("labs.biomarker.manage")} - - -
@@ -126,15 +116,6 @@ export default function LabsPage() { /> - - - - setDialogOpen(true)} /> ); diff --git a/src/app/settings/illness/page.tsx b/src/app/settings/illness/page.tsx new file mode 100644 index 000000000..b6b37b890 --- /dev/null +++ b/src/app/settings/illness/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/settings/labs/page.tsx b/src/app/settings/labs/page.tsx new file mode 100644 index 000000000..5805b5fa9 --- /dev/null +++ b/src/app/settings/labs/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/settings/vorsorge/page.tsx b/src/app/settings/vorsorge/page.tsx new file mode 100644 index 000000000..147b3d51f --- /dev/null +++ b/src/app/settings/vorsorge/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/components/illness/illness-settings.tsx b/src/components/illness/illness-settings.tsx new file mode 100644 index 000000000..7b33496f3 --- /dev/null +++ b/src/components/illness/illness-settings.tsx @@ -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 ( +
+
+
+

+ {t("moduleList.viewHeading")} +

+ +
+

+ {t("moduleList.viewDescription")} +

+
+ +
+

+ {t("moduleList.reorder.heading")} +

+ {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 49ffe5b9c..10101938c 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -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 ( + + +
+ + {episode.label} + +
+ + {t(`illness.type.${episode.type}`)} + + + {active + ? isChronic + ? t("illness.status.ongoing") + : t("illness.status.active") + : t("illness.status.recovered")} + + + {fmt.dateShort(new Date(episode.onsetAt))} + +
+
+
+ + onEdit(episode)} + onResolve={ + active && !isChronic ? () => onResolve(episode.id) : undefined + } + resolving={resolving} + /> +
+
+
+ ); + } + return ( @@ -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 = ( +
+ {parents.map((episode) => ( + + ))} +
+ ); + + if (collapsible) { + return ( +
+ + {open ? grid : null} +
+ ); + } + return (

{title}

-
- {parents.map((episode) => ( - - ))} -
+ {grid}
); } @@ -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() {
+ {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the + Illness settings page (view + reorder). */} + + {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every + other module, not the bespoke "neue Episode". */}
-

{t("illness.disclaimer")}

+ {/* 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 ? (
@@ -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)} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d53b69a68..7c31e6351 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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("/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 ( +
+ {truncated ? ( +

+ {t("labs.showingLatestOf", { shown, total })} +

+ ) : null} + + + {groups.map((group) => { + const row = ( +
+
+
+ + {group.analyte} + + +
+
+ + {formatLabValue(group.latest.value)}{" "} + {group.latest.unit} + + {formatDate(group.latest.takenAt)} +
+
+
+ r.value)} + /> + {group.biomarkerId ? ( + + ) : null} +
+
+ ); + return group.biomarkerId ? ( + + {row} + + ) : ( +
{row}
+ ); + })} +
+
+
+ ); + } + return (
{truncated ? ( diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx new file mode 100644 index 000000000..176dec090 --- /dev/null +++ b/src/components/labs/labs-settings.tsx @@ -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("/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 ( +
+
+
+

+ {t("moduleList.viewHeading")} +

+ +
+

+ {t("moduleList.viewDescription")} +

+
+ +
+

{t("labs.sort.heading")}

+
+ + setSortDir(e.target.value as ModuleSortDir)} + > + + + + +

+ {t("labs.sort.hint")} +

+
+
+ + {manual ? ( +
+

+ {t("moduleList.reorder.heading")} +

+ {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) : ( + + )} +
+ ) : null} + +
+

+ {t("labs.biomarker.manageTitle")} +

+ +
+
+ ); +} diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index bc60d3b41..748b7999e 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -73,9 +73,10 @@ describe(" 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(" loading + empty", () => { } as MeasurementReminder; remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); const html = render(); - expect(html).toContain("Log value"); + expect(html).toContain("Measure now"); expect(html).not.toContain(">Done<"); }); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 3ec86c63a..359192b65 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -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( null, ); const [form, setForm] = useState(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(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 = ( ); - // 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 = ( - - - - - - setManageOpen(true)}> - - {t("measurementReminders.manage.menuItem")} - - - + ); return ( @@ -382,8 +388,8 @@ export function VorsorgeSection({
- {addButton} {wrenchButton} + {addButton}
) : ( @@ -634,42 +640,6 @@ export function VorsorgeSection({ )} - {/* 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). */} - - {(reminders?.length ?? 0) === 0 ? ( -

- {t("measurementReminders.manage.empty")} -

- ) : ( -
    - {reminders?.map((reminder) => ( -
  • - - {resolveReminderLabel(reminder, t)} - - - update.mutate({ id: reminder.id, body: { enabled: next } }) - } - disabled={update.isPending} - aria-label={t("measurementReminders.enabledToggleAria")} - /> -
  • - ))} -
- )} -
- {isLoading && (
- {t("measurementReminders.addButton")} + {t("common.add")} } /> )} {!isLoading && (reminders?.length ?? 0) > 0 && ( -
    - {reminders?.map((reminder) => ( -
  • - onPrimaryAction(reminder)} - onRemove={() => remove.mutate(reminder.id)} - onEdit={() => openEdit(reminder)} - busy={ - satisfy.isPending || remove.isPending || update.isPending - } - /> -
  • - ))} +
      + {applyOrder(reminders ?? [], prefs.order, (r) => r.id).map( + (reminder) => ( +
    • + onPrimaryAction(reminder)} + onRemove={() => remove.mutate(reminder.id)} + onEdit={() => openEdit(reminder)} + busy={ + satisfy.isPending || remove.isPending || update.isPending + } + /> +
    • + ), + )}
    )} ); } +/** + * 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 && ( - - {t("measurementReminders.originCoach")} - - )} - {!reminder.enabled && ( - - {t("measurementReminders.disabledBadge")} - - )} - - ) : 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 && {cadence}} + {isCoach && ( + {t("measurementReminders.originCoach")} + )} + {!reminder.enabled && ( + + {t("measurementReminders.disabledBadge")} + + )} + + ); // 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({ ); - // 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 = ( - - - {t(`measurementReminders.${due.key}`, { days: due.days })} - - {cadence && {cadence}} - + // 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 = ( + ); + + const deleteDialog = ( + + + + + {t("measurementReminders.deleteConfirmTitle")} + + + {t("measurementReminders.deleteConfirmDescription")} + + + + {t("common.cancel")} + + {t("measurementReminders.delete")} + + + + + ); + + // v1.18.6 (MOD-03) — compact list row variant. + if (view === "list") { + return ( + <> + + +
    +
    + + {resolveReminderLabel(reminder, t)} + + {cadence && {cadence}} +
    +
    + + {t(`measurementReminders.${due.key}`, { days: due.days })} + + {categoryLabel} +
    +
    +
    + + {headerActions} +
    +
    +
    + {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 = ( +
    +
    + + {t("measurementReminders.nextDueLabel")} + + + + {t(`measurementReminders.${due.key}`, { days: due.days })} + + +
    + {lastValue && ( +
    + + {t("measurementReminders.lastDoneLabel")} + + {lastValue} +
    + )} +
    + ); return ( <> - {/* NEUTRAL card — no status-driven colour. Due state reads only through - the discreet badge below. Shares the medication card shell + tokens. */} - - - - + {/* 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. */} + + + + {nextLastSlot} - {reminder.endsOn && ( -

    - {t("measurementReminders.until", { - date: fmt.date(new Date(reminder.endsOn)), - })} -

    - )} - {reminder.location && ( -

    - {t("measurementReminders.location.prefix", { - location: reminder.location, - })} -

    - )} + {/* 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 && ( +
    +
    + {t("measurementReminders.adherence.label")} + + {isDue + ? t("measurementReminders.adherence.due") + : t("measurementReminders.adherence.onTrack")} + +
    + +
    + )} - {/* 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. */} -
    - -
    -
    -
    + {reminder.endsOn && ( +

    + {t("measurementReminders.until", { + date: fmt.date(new Date(reminder.endsOn)), + })} +

    + )} + {reminder.location && ( +

    + {t("measurementReminders.location.prefix", { + location: reminder.location, + })} +

    + )} - - - - - {t("measurementReminders.deleteConfirmTitle")} - - - {t("measurementReminders.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("measurementReminders.delete")} - - - - + {/* Bottom-pinned single primary action — green when due (MOD-06). */} +
    {primaryButton}
    +
    +
    + + {deleteDialog} ); } diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx new file mode 100644 index 000000000..47710d026 --- /dev/null +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -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 ( +
    +
    +
    +

    {t("moduleList.viewHeading")}

    + +
    +

    + {t("moduleList.viewDescription")} +

    +
    + +
    +

    + {t("moduleList.reorder.heading")} +

    + {isLoading ? ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) : ( + + )} +
    + +
    +

    + {t("measurementReminders.manage.title")} +

    +

    + {t("measurementReminders.manage.description")} +

    + {isLoading ? ( + + ) : (reminders?.length ?? 0) === 0 ? ( +

    + {t("measurementReminders.manage.empty")} +

    + ) : ( + + + {ordered.map((reminder) => ( +
    + + {resolveLabel(reminder, t)} + + + update.mutate({ + id: reminder.id, + body: { enabled: next }, + }) + } + disabled={update.isPending} + aria-label={t("measurementReminders.enabledToggleAria")} + /> +
    + ))} +
    +
    + )} +
    +
    + ); +} diff --git a/src/components/module-list/module-order-editor.tsx b/src/components/module-list/module-order-editor.tsx new file mode 100644 index 000000000..ce12ae97d --- /dev/null +++ b/src/components/module-list/module-order-editor.tsx @@ -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 ( +

    + {t("moduleList.reorder.empty")} +

    + ); + } + + return ( +
    + + + {items.map((item, index) => ( + + ))} + + +

    + {t("dashboard.dragHandleHint")} +

    +
    + ); +} + +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 ( +
    + + + + {item.name} + + {item.secondary ? ( + + {item.secondary} + + ) : null} + + + +
    + ); +} diff --git a/src/components/module-list/module-settings-frame.tsx b/src/components/module-list/module-settings-frame.tsx new file mode 100644 index 000000000..12c6887d1 --- /dev/null +++ b/src/components/module-list/module-settings-frame.tsx @@ -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 ( +
    +
    + +

    {title}

    +

    {description}

    +
    + {children} +
    + ); +} diff --git a/src/components/module-list/module-view-toggle.tsx b/src/components/module-list/module-view-toggle.tsx new file mode 100644 index 000000000..2fc25c0cb --- /dev/null +++ b/src/components/module-list/module-view-toggle.tsx @@ -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 ( +
    + {segments.map(({ value, label, icon: Icon }) => { + const active = view === value; + return ( + + ); + })} +
    + ); +} diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts new file mode 100644 index 000000000..8434b9615 --- /dev/null +++ b/src/lib/__tests__/module-list-prefs.test.ts @@ -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"]); + }); + }); +}); diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts new file mode 100644 index 000000000..c23a396ce --- /dev/null +++ b/src/lib/module-list-prefs.ts @@ -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; + 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( + 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 }; +}