diff --git a/messages/de.json b/messages/de.json index b9a9a1292..465d77959 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", @@ -6398,13 +6402,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." }, @@ -6413,6 +6430,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", @@ -6428,6 +6456,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", @@ -6475,9 +6517,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", @@ -6619,7 +6659,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 8b8f063d4..b34ca31d5 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", @@ -6398,13 +6402,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." }, @@ -6413,6 +6430,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", @@ -6428,6 +6456,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", @@ -6475,9 +6517,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", @@ -6619,7 +6659,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 ad87143c8..ce1b3b594 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", @@ -6398,13 +6402,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." }, @@ -6413,6 +6430,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", @@ -6428,6 +6456,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", @@ -6475,9 +6517,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", @@ -6619,7 +6659,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 1c74cf1bf..5da674e6b 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", @@ -6398,13 +6402,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." }, @@ -6413,6 +6430,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", @@ -6428,6 +6456,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", @@ -6475,9 +6517,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", @@ -6619,7 +6659,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 8f3123811..d9d741316 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", @@ -6398,13 +6402,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." }, @@ -6413,6 +6430,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", @@ -6428,6 +6456,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", @@ -6475,9 +6517,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", @@ -6619,7 +6659,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 5da1f13f1..f71af65cd 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", @@ -6398,13 +6402,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." }, @@ -6413,6 +6430,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", @@ -6428,6 +6456,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", @@ -6475,9 +6517,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", @@ -6619,7 +6659,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/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql new file mode 100644 index 000000000..389c580ec --- /dev/null +++ b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql @@ -0,0 +1,18 @@ +-- v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. +-- +-- Two nullable columns on `users` recording that the user acknowledged, once +-- at the start of onboarding, that HealthLog is a private tracking tool — not +-- a clinical assessment or diagnosis — and that they should talk to a doctor +-- if worried. The `*_version` pins exactly which copy was read (mirrors the +-- `research_mode_acknowledged_*` pair), so a future material wording change +-- can re-prompt without a schema change. +-- +-- Both nullable with no default: NULL = never acknowledged. The prompt only +-- gates fresh onboarding, so existing accounts (backfilled to NULL) are never +-- re-walled. This replaces the per-page / per-chart disclaimer banners that +-- were removed app-wide in the same release; the acknowledged legal text +-- stays reachable on the public privacy page. + +ALTER TABLE "users" + ADD COLUMN "disclaimer_acknowledged_at" TIMESTAMP(3), + ADD COLUMN "disclaimer_acknowledged_version" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f87097c6e..b4de47a28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,6 +279,17 @@ model User { // null on a Settings replay so the tour genuinely restarts. onboardingTourProgressJson Json? @map("onboarding_tour_progress_json") + // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown + // once at the start of onboarding: HealthLog is a private tracking tool, + // not a clinical assessment or diagnosis; if worried, talk to a doctor. + // Mirrors the `researchModeAcknowledged*` pair — the version pins exactly + // which copy the user read, so a future material wording change can ask + // again. Null = never acknowledged (legacy accounts read as never-asked; + // the prompt only gates fresh onboarding, so they are not re-walled). The + // acknowledged legal text stays reachable on the public privacy page. + disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at") + disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version") + // HealthKit (iOS) integration config — list of per-metric direction toggles. // Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt } healthKitConfigJson Json? @map("healthkit_config_json") diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index d23f63b8e..434f29221 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -71,6 +71,10 @@ export const GET = apiHandler(async () => { timezone: user.timezone, onboardingCompletedAt: user.onboardingCompletedAt, onboardingTourCompleted: user.onboardingTourCompleted, + // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null + // when never acknowledged; the onboarding welcome gate reads this to + // decide whether to require the acknowledgment before "Get started". + disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt, // v1.18.6 — resumable module-tour progress. Null when the user has // not started the tour; otherwise the resume point the launcher // seeds its index from. Fail-soft parse: a corrupt blob degrades to diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts new file mode 100644 index 000000000..5422adb60 --- /dev/null +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -0,0 +1,61 @@ +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; + +/** + * v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. + * + * POST { version: string } + * → stamps `users.disclaimer_acknowledged_at = now()` + + * `disclaimer_acknowledged_version = `. Idempotent: a repeat + * acknowledgment of the same version simply refreshes the timestamp. + * + * Replaces the per-page / per-chart disclaimer banners removed app-wide in + * the same release. The acknowledged legal text stays reachable on the public + * privacy page. Never auto-set; the onboarding welcome step is the one writer. + */ + +const bodySchema = z.object({ + // Echoed back from the client so a stale shell cannot silently record an + // acknowledgment of copy it never rendered. Bounded; the server pins the + // canonical version it persists regardless. + version: z.string().min(1).max(64), +}); + +export const POST = apiHandler(async (request) => { + const { user } = await requireAuth(); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = z.safeParse(bodySchema, rawBody); + if (!parsed.success) { + return apiError("Invalid input", 422); + } + + // The server pins the canonical version it stamps — the body version is + // only a freshness signal, never the source of truth. + await prisma.user.update({ + where: { id: user.id }, + data: { + disclaimerAcknowledgedAt: new Date(), + disclaimerAcknowledgedVersion: DISCLAIMER_VERSION, + }, + }); + + annotate({ + action: { name: "onboarding.disclaimer.acknowledge" }, + meta: { + version: DISCLAIMER_VERSION, + clientVersion: parsed.data.version, + }, + }); + + return apiSuccess({ acknowledgedVersion: DISCLAIMER_VERSION }); +}); 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/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx index 937e0fa0d..986a666f7 100644 --- a/src/components/onboarding/WelcomeCarousel.tsx +++ b/src/components/onboarding/WelcomeCarousel.tsx @@ -13,11 +13,16 @@ import { type LucideIcon, } from "lucide-react"; +import Link from "next/link"; + import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { scrollBehaviorForUser } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; +import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; /** * v1.4.25 W14b-Content — onboarding welcome carousel. @@ -72,11 +77,19 @@ export function WelcomeCarousel() { const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); + const { user } = useAuth(); const railRef = useRef(null); const liveRegionId = useId(); + const disclaimerId = useId(); const [active, setActive] = useState(0); const [advancing, setAdvancing] = useState(false); + // v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates + // "Get started". Pre-checked for an account that already acknowledged (a + // re-walk of step 0) so a returning user is not re-asked. + const [acknowledged, setAcknowledged] = useState( + () => user?.disclaimerAcknowledgedAt != null, + ); // Track which slide is centred in the rail. We observe each slide // with an IntersectionObserver — simpler and more reliable than @@ -128,9 +141,16 @@ export function WelcomeCarousel() { }, []); async function handleGetStarted() { - if (advancing) return; + if (advancing || !acknowledged) return; setAdvancing(true); try { + // v1.18.6 (DISC-02) — record the one-time acknowledgment before + // advancing. Skipped when the account already acknowledged (re-walk). + if (user?.disclaimerAcknowledgedAt == null) { + await apiPost("/api/onboarding/disclaimer", { + version: DISCLAIMER_VERSION, + }); + } await apiPost("/api/onboarding/step", { step: 1 }); await queryClient.invalidateQueries({ queryKey: ["auth"] }); router.push("/onboarding/1"); @@ -273,12 +293,39 @@ export function WelcomeCarousel() {
+ {/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This + replaces the per-page / per-chart disclaimer banners removed app-wide + in the same release; the full legal text stays reachable on the + public privacy page (link below). */} +
+ setAcknowledged(next === true)} + className="mt-0.5" + /> + +
+