diff --git a/messages/de.json b/messages/de.json
index 0d6890d30..83131c9e2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3647,31 +3647,72 @@
"back": "Zurück",
"skip": "Tour überspringen",
"done": "Verstanden",
- "restart": "Tour neu starten",
- "restartHint": "Zeige die Dashboard-Tour beim nächsten Aufruf erneut.",
- "restartConfirmation": "Die Tour startet bei deinem nächsten Dashboard-Besuch erneut.",
+ "restart": "Modul-Tour neu starten",
+ "restartHint": "Zeigt die geführte Modul-Tour beim nächsten Dashboard-Besuch von vorne.",
+ "restartConfirmation": "Die Modul-Tour startet bei deinem nächsten Dashboard-Besuch von vorne.",
"steps": {
- "tileStrip": {
- "title": "Deine Tageswerte",
- "body": "Die Kachel-Leiste zeigt den letzten Wert und den 7-Tage-Trend für jede Metrik — Gewicht, Blutdruck, Puls, Stimmung. Was du nicht brauchst, blendest du in Einstellungen → Dashboard aus."
+ "dashboardOverview": {
+ "title": "Dein Dashboard",
+ "body": "Das Dashboard bündelt alle Module auf einen Blick. Halte eine Kachel lange gedrückt, um sie neu anzuordnen oder auszublenden (Einstellungen → Dashboard-Layout). Jede Kachel ist nur so aussagekräftig wie die Daten dahinter — die folgenden Module füllen sie."
},
"quickAdd": {
- "title": "In zwei Taps erfasst",
- "body": "Über den Hinzufügen-Button kannst du Messungen oder Stimmungseinträge anlegen, ohne das Dashboard zu verlassen. Das Formular merkt sich deinen letzten Kontext und Tags."
+ "title": "In Sekunden erfasst",
+ "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein — Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights."
+ },
+ "measurements": {
+ "title": "Messwerte & Zielbereiche",
+ "body": "Messwerte zeigt Verlauf und Zielbereiche pro Metrik. Hidden Gem: Zielbereiche pro Wert definieren, damit Abweichungen farblich auffallen. Diese Reihen sind die Grundlage für Korrelationen im Coach und den Arztbericht."
+ },
+ "medications": {
+ "title": "Medikamente & Einnahmen",
+ "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Compliance-Säule im Dashboard und den Kontext der KI."
+ },
+ "labs": {
+ "title": "Laborwerte",
+ "body": "Labore erfasst Biomarker mit Referenzbereichen — Blutbild, Cholesterin, HbA1c. Stärke: Import und FHIR-Export. Auffällige Laborwerte tauchen als Belege in den Insights und im Arztbericht wieder auf."
+ },
+ "illness": {
+ "title": "Krankheits-Journal",
+ "body": "Das Krankheits-Journal dokumentiert akute Episoden und chronische Diagnosen samt Schüben. Versteckt: die Korrelation einer Episode mit deinen Messwerten und der Ruhemodus, der Reminder pausiert. So versteht der Coach, warum Werte zeitweise abweichen."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis anlegen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht."
+ },
+ "cycle": {
+ "title": "Zyklus",
+ "body": "Die Zyklus-Verfolgung protokolliert Phasen und Symptome. Versteckt: Symptome lassen sich Stimmung und Schlaf gegenüberstellen. Der Zyklus liefert Kontext für Schwankungen, die sonst unerklärlich wären."
+ },
+ "mood": {
+ "title": "Stimmung",
+ "body": "Stimmung erfasst dein Befinden und Tags in zwei Sekunden. Stärke: eigene Tags. Stimmungsverläufe korrelieren mit Schlaf, Medikamenten und Zyklus — der Coach erkennt Muster."
},
"insights": {
- "title": "Automatische Einblicke",
- "body": "Insights verwandelt deine Werte in eine klare Gesundheits-Zusammenfassung, mit Einordnung von Blutdruck, Gewicht und Stimmung. Eigener Provider-Key oder Standard — du entscheidest."
+ "title": "Insights",
+ "body": "Insights ist das Herzstück: belegbasierte, dynamische Auswertungen statt generischer Tipps — jede Aussage verweist auf deine echten Daten. Versteckt: das Layout ist anpassbar. Insights zieht alles zusammen, was die Module oben liefern."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Der Coach beantwortet Fragen zu deinen Daten im Dialog und nutzt Anamnese, Medikamente und Laborwerte als Kontext. Den KI-Anbieter wählst du selbst (eigener Schlüssel oder lokal). Er rechnet nichts neu — er liest dieselben Zahlen wie das Dashboard."
},
"integrations": {
- "title": "Geräte verbinden",
- "body": "In den Einstellungen verbindest du Withings (Auto-Sync für Gewicht, Blutdruck, Puls), moodLog oder deinen Auswertungs-Provider. Alles bleibt optional und verschlüsselt."
+ "title": "Integrationen",
+ "body": "Integrationen synchronisieren Withings, WHOOP, Polar, Oura und Apple Health automatisch. So füllen sich Gewicht, Schlaf und Erholung ohne manuelles Tippen — und nähren wiederum Insights und Coach."
+ },
+ "export": {
+ "title": "Arztbericht",
+ "body": "Der Arztbericht bündelt Messwerte, Medikamente, Laborwerte und Vorsorge als PDF oder FHIR für den Termin. Hier laufen alle Module zusammen — ein vollständiger Bericht entsteht nur bei gepflegten Daten."
},
"achievements": {
- "title": "Streaks und Badges sammeln",
- "body": "Die Achievements-Seite trackt tägliche Logging-Streaks, Blutdruck-Meilensteine und Konsistenz-Abzeichen. Ein kleiner Anreiz, dranzubleiben."
+ "title": "Erfolge",
+ "body": "Erfolge belohnen Konsistenz — Streaks und Meilensteine. Ein kleiner Motivations-Loop, der das Dranbleiben leichter macht."
+ },
+ "wrapUp": {
+ "title": "Das war die Tour",
+ "body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
}
- }
+ },
+ "moduleTrigger": "Diese Tour zeigen"
},
"shell": {
"back": "Zurück",
diff --git a/messages/en.json b/messages/en.json
index 0895bb5d8..092fe42e9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3647,31 +3647,72 @@
"back": "Back",
"skip": "Skip tour",
"done": "Got it",
- "restart": "Restart onboarding tour",
- "restartHint": "Replay the dashboard walkthrough from the start.",
- "restartConfirmation": "Tour will restart on your next dashboard visit.",
+ "restart": "Restart module tour",
+ "restartHint": "Replays the guided module tour from the start on your next dashboard visit.",
+ "restartConfirmation": "The module tour will restart from the beginning on your next dashboard visit.",
"steps": {
- "tileStrip": {
- "title": "Your daily numbers",
- "body": "The tile strip shows the latest reading and a 7-day trend for every metric you log — weight, blood pressure, pulse, mood. Hide what you don't need from Settings → Dashboard."
+ "dashboardOverview": {
+ "title": "Your dashboard",
+ "body": "The dashboard pulls every module together at a glance. Long-press a tile to reorder or hide it (Settings → Dashboard layout). Each tile is only as telling as the data behind it — the modules ahead fill them."
},
"quickAdd": {
- "title": "Log anything in two taps",
- "body": "Use the Add button to drop a measurement or mood entry without leaving the dashboard. The form remembers your last context and tags."
+ "title": "Logged in seconds",
+ "body": "Use “+ Add” to log any reading in seconds — weight, blood pressure, pulse, glucose. Hidden strength: backdate readings you forgot to enter. Everything you log here feeds trends, the health score, and the AI insights."
+ },
+ "measurements": {
+ "title": "Readings & target ranges",
+ "body": "Measurements shows the history and target range for each metric. Hidden gem: set a target range per value so deviations stand out in colour. These series are the basis for the Coach’s correlations and the doctor report."
+ },
+ "medications": {
+ "title": "Medications & intakes",
+ "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the compliance pillar on the dashboard and the AI’s context."
+ },
+ "labs": {
+ "title": "Lab values",
+ "body": "Labs captures biomarkers with reference ranges — blood count, cholesterol, HbA1c. Strength: import and FHIR export. Out-of-range lab values resurface as evidence in the insights and the doctor report."
+ },
+ "illness": {
+ "title": "Illness journal",
+ "body": "The illness journal records acute episodes and chronic diagnoses with their flares. Hidden: correlate an episode with your readings, and Rest Mode that pauses reminders. That’s how the Coach understands why values drift for a while."
+ },
+ "vorsorge": {
+ "title": "Preventive care",
+ "body": "Preventive care reminds you of check-ups and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report."
+ },
+ "cycle": {
+ "title": "Cycle",
+ "body": "Cycle tracking logs phases and symptoms. Hidden: compare symptoms against mood and sleep. The cycle gives context for swings that would otherwise look unexplained."
+ },
+ "mood": {
+ "title": "Mood",
+ "body": "Mood captures how you feel plus tags in two seconds. Strength: your own tags. Mood trends correlate with sleep, medications, and cycle — the Coach spots the patterns."
},
"insights": {
- "title": "Automatic insights",
- "body": "Insights turns your readings into a plain-English health summary, with classifications for blood pressure, weight trend and mood. Bring your own provider key or use the built-in default."
+ "title": "Insights",
+ "body": "Insights is the heart of it: evidence-based, dynamic analysis instead of generic tips — every statement points at your real data. Hidden: the layout is customizable. Insights pulls together everything the modules above provide."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "The Coach answers questions about your data in a dialogue, using your history, medications, and lab values as context. Pick your AI provider yourself (your own key or local). It recomputes nothing — it reads the same numbers as the dashboard."
},
"integrations": {
- "title": "Connect your devices",
- "body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your Insights provider. Everything stays optional and encrypted — and under Settings → Modules you can turn whole areas like sleep, mood or achievements on or off to keep HealthLog focused on what you track."
+ "title": "Integrations",
+ "body": "Integrations sync Withings, WHOOP, Polar, Oura, and Apple Health automatically. Weight, sleep, and recovery fill in without manual typing — and feed the insights and Coach in turn."
+ },
+ "export": {
+ "title": "Doctor report",
+ "body": "The doctor report bundles readings, medications, lab values, and preventive care as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data."
},
"achievements": {
- "title": "Earn streaks and badges",
- "body": "The Achievements page tracks daily logging streaks, blood-pressure milestones and consistency badges. A small nudge to keep going."
+ "title": "Achievements",
+ "body": "Achievements reward consistency — streaks and milestones. A small motivation loop that makes sticking with it easier."
+ },
+ "wrapUp": {
+ "title": "That’s the tour",
+ "body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
}
- }
+ },
+ "moduleTrigger": "Show this tour"
},
"shell": {
"back": "Back",
diff --git a/messages/es.json b/messages/es.json
index ecc64c96c..2110b8cd6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3647,31 +3647,72 @@
"back": "Atrás",
"skip": "Saltar tour",
"done": "Entendido",
- "restart": "Reiniciar tour",
- "restartHint": "Vuelve a mostrar el tour del panel la próxima vez.",
- "restartConfirmation": "El tour se reiniciará en tu próxima visita al panel.",
+ "restart": "Reiniciar la visita de módulos",
+ "restartHint": "Vuelve a reproducir la visita guiada de módulos desde el principio en tu próxima visita al panel.",
+ "restartConfirmation": "La visita de módulos se reiniciará desde el principio en tu próxima visita al panel.",
"steps": {
- "tileStrip": {
- "title": "Tus valores del día",
- "body": "La fila de tarjetas muestra el último valor y la tendencia de 7 días de cada métrica — peso, tensión arterial, pulso, ánimo. Oculta lo que no necesites en Ajustes → Panel."
+ "dashboardOverview": {
+ "title": "Tu panel",
+ "body": "El panel reúne todos los módulos de un vistazo. Mantén pulsada una tarjeta para reordenarla u ocultarla (Ajustes → Diseño del panel). Cada tarjeta vale tanto como los datos que tiene detrás: los módulos siguientes los rellenan."
},
"quickAdd": {
- "title": "Registrado en dos toques",
- "body": "Con el botón Añadir puedes registrar mediciones o entradas de ánimo sin salir del panel. El formulario recuerda tu último contexto y etiquetas."
+ "title": "Registrado en segundos",
+ "body": "Con «+ Añadir» registras cualquier medición en segundos: peso, tensión, pulso, glucosa. Fuerza oculta: fechar valores olvidados. Todo lo que registras aquí alimenta las tendencias, la puntuación de salud y las ideas de IA."
+ },
+ "measurements": {
+ "title": "Mediciones y rangos objetivo",
+ "body": "Mediciones muestra el historial y el rango objetivo de cada métrica. Joya oculta: define un rango por valor para que las desviaciones resalten en color. Estas series son la base de las correlaciones del Coach y del informe médico."
+ },
+ "medications": {
+ "title": "Medicación y tomas",
+ "body": "Medicación gestiona horarios, tomas, existencias y titulación. Oculto: planes recurrentes (RRULE/rotativos) y avisos de poco stock. Tu tasa de tomas alimenta el pilar de cumplimiento del panel y el contexto de la IA."
+ },
+ "labs": {
+ "title": "Valores de laboratorio",
+ "body": "Laboratorio recoge biomarcadores con rangos de referencia: hemograma, colesterol, HbA1c. Fuerza: importación y exportación FHIR. Los valores fuera de rango reaparecen como pruebas en las ideas y en el informe médico."
+ },
+ "illness": {
+ "title": "Diario de enfermedades",
+ "body": "El diario de enfermedades registra episodios agudos y diagnósticos crónicos con sus brotes. Oculto: correlacionar un episodio con tus mediciones y el Modo descanso que pausa los recordatorios. Así el Coach entiende por qué los valores varían un tiempo."
+ },
+ "vorsorge": {
+ "title": "Prevención",
+ "body": "Prevención te recuerda revisiones y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico."
+ },
+ "cycle": {
+ "title": "Ciclo",
+ "body": "El seguimiento del ciclo registra fases y síntomas. Oculto: comparar síntomas con el ánimo y el sueño. El ciclo aporta contexto a variaciones que de otro modo parecerían inexplicables."
+ },
+ "mood": {
+ "title": "Ánimo",
+ "body": "Ánimo capta cómo te sientes y etiquetas en dos segundos. Fuerza: tus propias etiquetas. Las tendencias de ánimo se correlacionan con el sueño, la medicación y el ciclo: el Coach detecta los patrones."
},
"insights": {
- "title": "Insights automáticos",
- "body": "Insights convierte tus valores en un resumen claro de salud, con interpretación de tensión arterial, peso y ánimo. Clave propia o por defecto — tú eliges."
+ "title": "Ideas",
+ "body": "Ideas es el corazón: análisis dinámico y basado en pruebas en lugar de consejos genéricos; cada afirmación apunta a tus datos reales. Oculto: el diseño es personalizable. Ideas reúne todo lo que aportan los módulos anteriores."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "El Coach responde preguntas sobre tus datos en diálogo, usando tu historial, medicación y valores de laboratorio como contexto. Tú eliges el proveedor de IA (tu propia clave o local). No recalcula nada: lee las mismas cifras que el panel."
},
"integrations": {
- "title": "Conectar dispositivos",
- "body": "En Ajustes conectas Withings (sincronización automática de peso, tensión y pulso), moodLog o tu proveedor de análisis. Todo es opcional y queda cifrado."
+ "title": "Integraciones",
+ "body": "Las integraciones sincronizan Withings, WHOOP, Polar, Oura y Apple Health automáticamente. Peso, sueño y recuperación se rellenan sin teclear, y a su vez alimentan las ideas y el Coach."
+ },
+ "export": {
+ "title": "Informe médico",
+ "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y prevención en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados."
},
"achievements": {
- "title": "Acumula rachas e insignias",
- "body": "La página de Logros lleva la cuenta de rachas de registro diario, hitos de tensión arterial e insignias de constancia. Un pequeño empujón para no abandonar."
+ "title": "Logros",
+ "body": "Los logros premian la constancia: rachas e hitos. Un pequeño bucle de motivación que facilita seguir adelante."
+ },
+ "wrapUp": {
+ "title": "Esto es la visita",
+ "body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
}
- }
+ },
+ "moduleTrigger": "Mostrar esta visita"
},
"shell": {
"back": "Atrás",
diff --git a/messages/fr.json b/messages/fr.json
index ea1705a4a..8f6e5a3d4 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3647,31 +3647,72 @@
"back": "Précédent",
"skip": "Passer la visite",
"done": "Compris",
- "restart": "Relancer la visite",
- "restartHint": "Affiche à nouveau la visite du tableau de bord à la prochaine ouverture.",
- "restartConfirmation": "La visite redémarrera lors de ta prochaine visite du tableau de bord.",
+ "restart": "Relancer la visite des modules",
+ "restartHint": "Rejoue la visite guidée des modules depuis le début lors de votre prochaine ouverture du tableau de bord.",
+ "restartConfirmation": "La visite des modules redémarrera depuis le début à votre prochaine ouverture du tableau de bord.",
"steps": {
- "tileStrip": {
- "title": "Tes valeurs du jour",
- "body": "La barre de tuiles affiche la dernière valeur et la tendance 7 jours de chaque métrique — poids, tension, pouls, humeur. Masque ce qui ne te sert pas dans Paramètres → Tableau de bord."
+ "dashboardOverview": {
+ "title": "Votre tableau de bord",
+ "body": "Le tableau de bord rassemble tous les modules d’un coup d’œil. Appuyez longuement sur une tuile pour la réorganiser ou la masquer (Réglages → Disposition du tableau de bord). Une tuile ne vaut que par les données derrière elle — les modules suivants les remplissent."
},
"quickAdd": {
- "title": "Saisi en deux tapotements",
- "body": "Via le bouton Ajouter, tu saisis des mesures ou des humeurs sans quitter le tableau de bord. Le formulaire mémorise le dernier contexte et les étiquettes."
+ "title": "Saisi en quelques secondes",
+ "body": "Avec « + Ajouter », saisissez n’importe quelle mesure en quelques secondes — poids, tension, pouls, glycémie. Atout caché : antidater les valeurs oubliées. Tout ce que vous saisissez ici alimente les tendances, le score santé et les analyses IA."
+ },
+ "measurements": {
+ "title": "Mesures et plages cibles",
+ "body": "Mesures affiche l’historique et la plage cible de chaque métrique. Astuce cachée : définir une plage par valeur pour que les écarts ressortent en couleur. Ces séries sont la base des corrélations du Coach et du rapport médical."
+ },
+ "medications": {
+ "title": "Médicaments et prises",
+ "body": "Médicaments gère plannings, prises, stock et titration. Caché : plans récurrents (RRULE/glissants) et alertes de stock bas. Votre taux de prise alimente le pilier d’observance du tableau de bord et le contexte de l’IA."
+ },
+ "labs": {
+ "title": "Valeurs de laboratoire",
+ "body": "Laboratoire recense les biomarqueurs avec plages de référence — hémogramme, cholestérol, HbA1c. Atout : import et export FHIR. Les valeurs hors plage réapparaissent comme preuves dans les analyses et le rapport médical."
+ },
+ "illness": {
+ "title": "Journal des maladies",
+ "body": "Le journal des maladies consigne les épisodes aigus et les diagnostics chroniques avec leurs poussées. Caché : corréler un épisode à vos mesures et le Mode repos qui suspend les rappels. Le Coach comprend ainsi pourquoi les valeurs dérivent un temps."
+ },
+ "vorsorge": {
+ "title": "Prévention",
+ "body": "Prévention vous rappelle examens et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical."
+ },
+ "cycle": {
+ "title": "Cycle",
+ "body": "Le suivi du cycle consigne phases et symptômes. Caché : comparer les symptômes à l’humeur et au sommeil. Le cycle donne du contexte aux variations qui paraîtraient autrement inexpliquées."
+ },
+ "mood": {
+ "title": "Humeur",
+ "body": "Humeur saisit votre ressenti et des tags en deux secondes. Atout : vos propres tags. Les tendances d’humeur se corrèlent au sommeil, aux médicaments et au cycle — le Coach repère les schémas."
},
"insights": {
- "title": "Insights automatiques",
- "body": "Insights transforme tes valeurs en un résumé santé clair, avec un classement de la tension, du poids et de l’humeur. Clé personnelle ou par défaut — c’est toi qui choisis."
+ "title": "Analyses",
+ "body": "Analyses, c’est le cœur : une analyse dynamique et fondée sur des preuves plutôt que des conseils génériques — chaque affirmation pointe vos données réelles. Caché : la disposition est personnalisable. Analyses réunit tout ce que les modules ci-dessus fournissent."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Le Coach répond à vos questions sur vos données en dialogue, en utilisant antécédents, médicaments et résultats comme contexte. Vous choisissez le fournisseur IA (votre clé ou local). Il ne recalcule rien — il lit les mêmes chiffres que le tableau de bord."
},
"integrations": {
- "title": "Connecter des appareils",
- "body": "Dans les paramètres, tu connectes Withings (synchronisation automatique du poids, de la tension et du pouls), moodLog ou ton fournisseur d’analyse. Tout reste optionnel et chiffré."
+ "title": "Intégrations",
+ "body": "Les intégrations synchronisent Withings, WHOOP, Polar, Oura et Apple Santé automatiquement. Poids, sommeil et récupération se remplissent sans saisie, et alimentent à leur tour les analyses et le Coach."
+ },
+ "export": {
+ "title": "Rapport médical",
+ "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et prévention en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues."
},
"achievements": {
- "title": "Collecter séries et badges",
- "body": "La page Succès suit les séries de saisie quotidienne, les paliers de tension et les badges de constance. Un petit coup de pouce pour rester régulier."
+ "title": "Réussites",
+ "body": "Les réussites récompensent la régularité — séries et jalons. Une petite boucle de motivation qui aide à tenir bon."
+ },
+ "wrapUp": {
+ "title": "C’est la fin de la visite",
+ "body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
}
- }
+ },
+ "moduleTrigger": "Afficher cette visite"
},
"shell": {
"back": "Retour",
diff --git a/messages/it.json b/messages/it.json
index 3aef973cb..2f80c5675 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3647,31 +3647,72 @@
"back": "Indietro",
"skip": "Salta tour",
"done": "Ho capito",
- "restart": "Riavvia tour",
- "restartHint": "Mostra di nuovo il tour della dashboard alla prossima apertura.",
- "restartConfirmation": "Il tour ripartirà alla tua prossima visita alla dashboard.",
+ "restart": "Riavvia la visita ai moduli",
+ "restartHint": "Riproduce la visita guidata ai moduli dall’inizio al tuo prossimo accesso alla dashboard.",
+ "restartConfirmation": "La visita ai moduli ripartirà dall’inizio al tuo prossimo accesso alla dashboard.",
"steps": {
- "tileStrip": {
- "title": "I tuoi valori di oggi",
- "body": "La striscia di tile mostra l’ultimo valore e il trend a 7 giorni di ogni metrica — peso, pressione, pulsazioni, umore. Nascondi ciò che non ti serve in Impostazioni → Dashboard."
+ "dashboardOverview": {
+ "title": "La tua dashboard",
+ "body": "La dashboard riunisce tutti i moduli a colpo d’occhio. Tieni premuta una scheda per riordinarla o nasconderla (Impostazioni → Layout dashboard). Una scheda vale quanto i dati che ha dietro: i moduli successivi li riempiono."
},
"quickAdd": {
- "title": "Registrato in due tap",
- "body": "Con il pulsante Aggiungi registri misurazioni o voci di umore senza lasciare la dashboard. Il modulo ricorda il contesto e i tag più recenti."
+ "title": "Registrato in pochi secondi",
+ "body": "Con « + Aggiungi » registri qualsiasi misurazione in pochi secondi — peso, pressione, polso, glicemia. Forza nascosta: retrodatare i valori dimenticati. Tutto ciò che inserisci qui alimenta tendenze, punteggio salute e analisi IA."
+ },
+ "measurements": {
+ "title": "Misurazioni e intervalli obiettivo",
+ "body": "Misurazioni mostra lo storico e l’intervallo obiettivo per ogni metrica. Chicca nascosta: definire un intervallo per valore così gli scostamenti risaltano a colori. Queste serie sono la base delle correlazioni del Coach e del referto medico."
+ },
+ "medications": {
+ "title": "Farmaci e assunzioni",
+ "body": "Farmaci gestisce orari, assunzioni, scorte e titolazione. Nascosto: piani ricorrenti (RRULE/scorrevoli) e avvisi di scorte basse. La tua aderenza alimenta il pilastro di compliance nella dashboard e il contesto dell’IA."
+ },
+ "labs": {
+ "title": "Valori di laboratorio",
+ "body": "Laboratorio raccoglie i biomarcatori con intervalli di riferimento — emocromo, colesterolo, HbA1c. Forza: importazione ed esportazione FHIR. I valori fuori intervallo riemergono come prove nelle analisi e nel referto medico."
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "body": "Il diario delle malattie registra episodi acuti e diagnosi croniche con le loro riacutizzazioni. Nascosto: correlare un episodio alle tue misurazioni e la Modalità riposo che mette in pausa i promemoria. Così il Coach capisce perché i valori variano per un periodo."
+ },
+ "vorsorge": {
+ "title": "Prevenzione",
+ "body": "Prevenzione ti ricorda controlli e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico."
+ },
+ "cycle": {
+ "title": "Ciclo",
+ "body": "Il monitoraggio del ciclo registra fasi e sintomi. Nascosto: confrontare i sintomi con umore e sonno. Il ciclo dà contesto alle variazioni che altrimenti sembrerebbero inspiegabili."
+ },
+ "mood": {
+ "title": "Umore",
+ "body": "Umore cattura come ti senti e dei tag in due secondi. Forza: i tuoi tag personalizzati. Gli andamenti dell’umore si correlano con sonno, farmaci e ciclo — il Coach coglie gli schemi."
},
"insights": {
- "title": "Insights automatici",
- "body": "Insights trasforma i tuoi valori in un riepilogo chiaro della salute, con inquadramento di pressione, peso e umore. Chiave personale o predefinita — scegli tu."
+ "title": "Analisi",
+ "body": "Analisi è il cuore: analisi dinamica e basata su prove invece di consigli generici — ogni affermazione punta ai tuoi dati reali. Nascosto: il layout è personalizzabile. Analisi raccoglie tutto ciò che forniscono i moduli sopra."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Il Coach risponde alle domande sui tuoi dati in dialogo, usando anamnesi, farmaci e valori di laboratorio come contesto. Scegli tu il fornitore IA (chiave tua o locale). Non ricalcola nulla: legge gli stessi numeri della dashboard."
},
"integrations": {
- "title": "Collegare dispositivi",
- "body": "Dalle impostazioni colleghi Withings (sincronizzazione automatica di peso, pressione e pulsazioni), moodLog o il tuo provider di analisi. Tutto resta opzionale e cifrato."
+ "title": "Integrazioni",
+ "body": "Le integrazioni sincronizzano Withings, WHOOP, Polar, Oura e Apple Salute automaticamente. Peso, sonno e recupero si riempiono senza digitare e a loro volta alimentano analisi e Coach."
+ },
+ "export": {
+ "title": "Referto medico",
+ "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e prevenzione in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati."
},
"achievements": {
- "title": "Accumula serie e badge",
- "body": "La pagina Traguardi tiene traccia delle serie di registrazioni giornaliere, dei traguardi di pressione e dei badge di costanza. Un piccolo incentivo per non mollare."
+ "title": "Traguardi",
+ "body": "I traguardi premiano la costanza — serie e tappe. Un piccolo ciclo di motivazione che rende più facile non mollare."
+ },
+ "wrapUp": {
+ "title": "Questa è la visita guidata",
+ "body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
}
- }
+ },
+ "moduleTrigger": "Mostra questa visita"
},
"shell": {
"back": "Indietro",
diff --git a/messages/pl.json b/messages/pl.json
index d91c6f7bd..2c637227b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3647,31 +3647,72 @@
"back": "Wstecz",
"skip": "Pomiń tour",
"done": "Rozumiem",
- "restart": "Uruchom tour ponownie",
- "restartHint": "Pokaż tour panelu przy następnym wejściu.",
- "restartConfirmation": "Tour ruszy ponownie przy twojej następnej wizycie w panelu.",
+ "restart": "Uruchom przewodnik modułów ponownie",
+ "restartHint": "Odtwarza przewodnik po modułach od początku przy następnej wizycie na pulpicie.",
+ "restartConfirmation": "Przewodnik po modułach uruchomi się od początku przy następnej wizycie na pulpicie.",
"steps": {
- "tileStrip": {
- "title": "Twoje wartości dzienne",
- "body": "Pasek kafelków pokazuje ostatnią wartość i 7-dniowy trend dla każdej metryki — waga, ciśnienie, tętno, nastrój. Niepotrzebne wyłączysz w Ustawienia → Panel."
+ "dashboardOverview": {
+ "title": "Twój pulpit",
+ "body": "Pulpit zbiera wszystkie moduły na jednym ekranie. Przytrzymaj kafelek, aby go przestawić lub ukryć (Ustawienia → Układ pulpitu). Kafelek jest wart tyle, ile dane za nim — kolejne moduły je wypełniają."
},
"quickAdd": {
- "title": "Zarejestrowane w dwóch stuknięciach",
- "body": "Przyciskiem Dodaj zapisujesz pomiary lub nastroje bez opuszczania panelu. Formularz zapamiętuje ostatni kontekst i tagi."
+ "title": "Zapisane w kilka sekund",
+ "body": "Przez „+ Dodaj” zapiszesz każdy pomiar w sekundy — wagę, ciśnienie, tętno, glukozę. Ukryta moc: datowanie wstecz zapomnianych wartości. Wszystko, co tu wpiszesz, zasila trendy, wynik zdrowia i analizy AI."
+ },
+ "measurements": {
+ "title": "Pomiary i zakresy docelowe",
+ "body": "Pomiary pokazują historię i zakres docelowy każdej metryki. Ukryta perełka: ustaw zakres na wartość, aby odchylenia rzucały się w oczy kolorem. Te serie są podstawą korelacji Coacha i raportu dla lekarza."
+ },
+ "medications": {
+ "title": "Leki i przyjęcia",
+ "body": "Leki zarządzają harmonogramami, przyjęciami, zapasem i titracją. Ukryte: plany cykliczne (RRULE/przesuwne) i ostrzeżenia o niskim zapasie. Twój wskaźnik przyjęć zasila filar stosowania na pulpicie oraz kontekst AI."
+ },
+ "labs": {
+ "title": "Wyniki laboratoryjne",
+ "body": "Laboratorium gromadzi biomarkery z zakresami referencyjnymi — morfologia, cholesterol, HbA1c. Atut: import i eksport FHIR. Wartości poza zakresem wracają jako dowody w analizach i w raporcie dla lekarza."
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "body": "Dziennik chorób zapisuje ostre epizody i przewlekłe diagnozy wraz z zaostrzeniami. Ukryte: korelacja epizodu z pomiarami oraz Tryb odpoczynku wstrzymujący przypomnienia. Dzięki temu Coach rozumie, czemu wartości przez chwilę odbiegają."
+ },
+ "vorsorge": {
+ "title": "Profilaktyka",
+ "body": "Profilaktyka przypomina o badaniach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza."
+ },
+ "cycle": {
+ "title": "Cykl",
+ "body": "Śledzenie cyklu zapisuje fazy i objawy. Ukryte: zestawienie objawów z nastrojem i snem. Cykl daje kontekst wahaniom, które inaczej wyglądałyby na niewyjaśnione."
+ },
+ "mood": {
+ "title": "Nastrój",
+ "body": "Nastrój zapisuje samopoczucie i tagi w dwie sekundy. Atut: własne tagi. Trendy nastroju korelują ze snem, lekami i cyklem — Coach dostrzega wzorce."
},
"insights": {
- "title": "Automatyczne insights",
- "body": "Insights zamienia twoje wartości w przejrzysty przegląd zdrowia, z oceną ciśnienia, wagi i nastroju. Własny klucz lub domyślny — ty decydujesz."
+ "title": "Analizy",
+ "body": "Analizy to serce aplikacji: dynamiczna analiza oparta na dowodach zamiast ogólnych porad — każde stwierdzenie wskazuje na Twoje realne dane. Ukryte: układ można dostosować. Analizy łączą wszystko, co dostarczają moduły powyżej."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Coach odpowiada na pytania o Twoje dane w dialogu, korzystając z wywiadu, leków i wyników jako kontekstu. Dostawcę AI wybierasz sam (własny klucz lub lokalnie). Niczego nie przelicza — czyta te same liczby co pulpit."
},
"integrations": {
- "title": "Połącz urządzenia",
- "body": "W ustawieniach łączysz Withings (auto-sync wagi, ciśnienia i tętna), moodLog lub dostawcę analizy. Wszystko zostaje opcjonalne i zaszyfrowane."
+ "title": "Integracje",
+ "body": "Integracje synchronizują Withings, WHOOP, Polar, Oura i Apple Health automatycznie. Waga, sen i regeneracja uzupełniają się bez wpisywania, a z kolei zasilają analizy i Coacha."
+ },
+ "export": {
+ "title": "Raport dla lekarza",
+ "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i profilaktykę w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych."
},
"achievements": {
- "title": "Zbieraj serie i odznaki",
- "body": "Strona Osiągnięć śledzi codzienne serie zapisów, kamienie milowe ciśnienia i odznaki konsekwencji. Drobna motywacja do trzymania się planu."
+ "title": "Osiągnięcia",
+ "body": "Osiągnięcia nagradzają konsekwencję — passy i kamienie milowe. Mała pętla motywacji, dzięki której łatwiej wytrwać."
+ },
+ "wrapUp": {
+ "title": "To koniec przewodnika",
+ "body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
}
- }
+ },
+ "moduleTrigger": "Pokaż ten przewodnik"
},
"shell": {
"back": "Wstecz",
diff --git a/src/app/page.tsx b/src/app/page.tsx
index c7a111143..4d03b3ff8 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -52,7 +52,6 @@ import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { TrendHint } from "@/components/charts/trend-hint";
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
-import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
@@ -753,15 +752,10 @@ export default function DashboardPage() {
/>
))}
- {/* v1.4.15 Phase B5 — spotlight tour for first-time users.
- * Self-gates on `user.onboardingTourCompleted` (DB flag) plus
- * a session-storage dismiss guard. We pass `ready=true` only
- * after analytics has resolved — the tour anchors to the tile
- * strip and we don't want the cutout snapping to a 0×0
- * placeholder before tiles render. The launcher mounts a no-op
- * `null` when the user has already seen the tour, so this
- * line is free for established users. */}
-
+ {/* v1.18.6 — the spotlight tour launcher moved to the app-shell
+ * (`AuthShell`) so its overlay survives the cross-page
+ * `router.push`es the module tour makes. The dashboard no longer
+ * mounts it. */}
{/* Quick Entry Sheets — bottom-sheet on `
+ {/* v1.18.6 — the module-tour launcher lives at the shell level so its
+ overlay survives the cross-page `router.push`es the tour makes. It
+ self-gates: it only auto-opens on the dashboard for a user who has
+ not finished/dismissed the tour, and otherwise renders null. Stays
+ out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the
+ proxy blocks for demo visitors). */}
+ {!demoMode && }
{/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits
`/api/insights/chat`, which is not in the proxy's
`DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent
diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/DoneScreen.tsx
index 9b924a123..46d8ca25b 100644
--- a/src/components/onboarding/DoneScreen.tsx
+++ b/src/components/onboarding/DoneScreen.tsx
@@ -1,9 +1,12 @@
"use client";
import Link from "next/link";
+import { useEffect } from "react";
import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { useAuth } from "@/hooks/use-auth";
+import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -18,10 +21,21 @@ import { useTranslations } from "@/lib/i18n/context";
* through `next/link` so the regular client-side navigation kicks in
* (the proxy redirect was cleared with the same step-API write that
* landed the user here).
+ *
+ * v1.18.6 — on mount we write the per-user wizard-return marker so the
+ * shell-level `` auto-opens the module tour immediately
+ * on the next dashboard arrival (sequenced after the post-wizard
+ * grace), instead of waiting out the 24 h fallback. Whichever exit the
+ * user picks from this screen, the next `/` mount finds the marker.
*/
export function DoneScreen() {
const { t } = useTranslations();
+ const { user } = useAuth();
+
+ useEffect(() => {
+ if (user?.id) setTourReferrer(user.id);
+ }, [user?.id]);
return (
({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => "/",
+}));
import { I18nProvider } from "@/lib/i18n/context";
import { OnboardingTour } from "../tour";
-function renderTour(includeAchievements = true) {
+function renderTour(modules?: Record) {
return renderToStaticMarkup(
-
+
,
);
}
@@ -31,25 +35,23 @@ describe("", () => {
expect(html).toContain('aria-labelledby="onboarding-tour-title"');
});
- it("starts on the tile-strip step (1 of 5 by default)", () => {
+ it("starts on the dashboard-overview step (1 of 15 by default)", () => {
const html = renderTour();
- expect(html).toContain("Step 1 of 5");
- expect(html).toContain("Your daily numbers");
- // Body of the first step should mention the tile strip context.
- expect(html).toContain("tile strip");
+ expect(html).toContain("Step 1 of 15");
});
- it("drops the achievements stop when the flag is off (1 of 4)", () => {
- const html = renderTour(false);
- expect(html).toContain("Step 1 of 4");
+ it("keeps the counter honest when modules are disabled", () => {
+ // cycle + mood + achievements off ⇒ 12 stops.
+ const html = renderTour({
+ cycle: false,
+ mood: false,
+ achievements: false,
+ });
+ expect(html).toContain("Step 1 of 12");
});
it("exposes a skip control with an accessible label", () => {
const html = renderTour();
- // After v1.4.33 F2 the dim panels are purely visual
- // (pointer-events: none) so skip lives entirely on the explicit
- // tooltip-footer Skip button. The visible label still has to be
- // there — both for sighted users and for screen readers.
expect(html).toContain("Skip tour");
});
@@ -77,8 +79,7 @@ describe("", () => {
,
);
- expect(html).toContain("Schritt 1 von 5");
- expect(html).toContain("Deine Tageswerte");
+ expect(html).toContain("Schritt 1 von 15");
expect(html).toContain("Tour überspringen");
});
});
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index 3095542df..4254e0770 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -47,11 +47,13 @@
* manual replay always works.
*/
+import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api/api-fetch";
+import type { TourStopId } from "@/lib/onboarding/tour-state";
import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
@@ -90,6 +92,16 @@ export function shouldAutoLaunchTour(args: {
onboardingTourCompleted: boolean;
onboardingCompletedAt: string | null;
nowMs: number;
+ /**
+ * v1.18.6 — true when the user has just returned from the wizard's
+ * DoneScreen this session (the `tourReferrerKey` marker). A fresh
+ * user finishing anamnesis should get the module tour IMMEDIATELY —
+ * the post-wizard grace below still sequences it after the toast.
+ * The old 24 h delay over-corrected and removed first-run capability
+ * education entirely; it now only applies to a user who SKIPPED the
+ * wizard (no referrer marker, completion ≥ 24 h ago).
+ */
+ justFromWizard?: boolean;
}): boolean {
if (args.onboardingTourCompleted) return false;
if (args.onboardingCompletedAt == null) return false;
@@ -98,6 +110,9 @@ export function shouldAutoLaunchTour(args: {
// unparseable case as "no completion yet" so a malformed payload
// never auto-launches the tour into an unsuspecting brand-new user.
if (Number.isNaN(completedMs)) return false;
+ // Fresh-from-anamnesis: launch now (sequenced after the post-wizard
+ // grace by the launcher), no 24 h wait.
+ if (args.justFromWizard) return true;
return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS;
}
@@ -189,6 +204,22 @@ function readAndClearForceLaunch(userId: string): boolean {
* does not consult sessionStorage at all (the gating effect waits
* for `inputsReady` first).
*/
+/**
+ * v1.18.6 — write the wizard-return marker. Called from the wizard's
+ * `DoneScreen` on mount so the launcher knows the user JUST finished
+ * anamnesis and should get the module tour immediately (sequenced after
+ * the post-wizard grace) rather than after the 24 h fallback delay.
+ * Per-user keyed so a shared browser keeps each identity's state apart.
+ */
+export function setTourReferrer(userId: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.sessionStorage.setItem(tourReferrerKey(userId), "1");
+ } catch {
+ /* ignore — sandboxed iframes etc. */
+ }
+}
+
function readAndClearJustFromWizard(userId: string): boolean {
if (typeof window === "undefined") return false;
try {
@@ -233,24 +264,30 @@ function clearSessionDismissed(userId: string) {
interface TourLauncherProps {
/**
- * The dashboard passes a sentinel — true once analytics has
- * resolved at least once. Without this gate the spotlight would
- * try to anchor to tiles that haven't rendered yet, and Recharts
- * widgets defer mounting too. This is a simple, race-free signal
- * the dashboard already computes.
+ * v1.18.6 — optional readiness sentinel. The dashboard used to pass
+ * `true` once analytics resolved so the spotlight didn't anchor to a
+ * 0×0 tile. The overlay now polls for its anchor (bounded retry), so
+ * the gate is no longer load-bearing; it defaults to `true` for the
+ * shell-level mount. The launcher still only auto-opens on the
+ * dashboard route, where the first stop's anchor lives.
*/
- ready: boolean;
+ ready?: boolean;
}
-export function TourLauncher({ ready }: TourLauncherProps) {
+export function TourLauncher({ ready = true }: TourLauncherProps) {
const { user, isAuthenticated, isLoading } = useAuth();
const queryClient = useQueryClient();
+ const pathname = usePathname();
// `null` = decision not yet made; `true` = render the tour;
// `false` = don't render. We only flip null→true ONCE per mount;
// closing the tour transitions true→false and we never re-open
// unless the restart event fires.
const [showTour, setShowTour] = useState(null);
+ // v1.18.6 — when the tour opens for a single-module re-entry
+ // ("Diese Tour zeigen" on a module page) this carries the stop id so
+ // the overlay renders just that card. `null` = the full tour.
+ const [singleStop, setSingleStop] = useState(null);
// Track post-wizard deferral as a boolean so the render path stays
// pure (no Date.now() comparisons). The effect below reads / clears it.
const [deferredFromWizard, setDeferredFromWizard] = useState(false);
@@ -275,7 +312,14 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// tour on their next page-load — acceptable for a one-shot welcome).
const [mountedAtMs] = useState(() => Date.now());
- const inputsReady = !isLoading && isAuthenticated && !!user && ready;
+ // v1.18.6 — the launcher now mounts at the app-shell level so the
+ // overlay survives the cross-page `router.push`es the tour makes. It
+ // must only AUTO-open on the dashboard, where the first stop's anchor
+ // lives. A manual re-entry / restart can fire from any route (handled
+ // by the event listeners below, which don't consult this gate).
+ const onDashboard = pathname === "/";
+ const inputsReady =
+ !isLoading && isAuthenticated && !!user && ready && onDashboard;
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
// `seededUserId` — React-recommended for "compute initial state
// from props once they arrive"). Pure: branches on prop values
@@ -289,6 +333,10 @@ export function TourLauncher({ ready }: TourLauncherProps) {
decidedFor.flag !== user.onboardingTourCompleted)
) {
setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted });
+ // v1.18.6 — consume the wizard-return marker once so the
+ // fresh-from-anamnesis path launches immediately (sequenced after
+ // the post-wizard grace) instead of waiting 24 h.
+ const justFromWizard = readAndClearJustFromWizard(user.id);
// sessionStorage reads happen via per-user keyed helpers, so a
// browser shared by two HealthLog users (admin impersonation,
// family laptop, etc.) keeps each user's tour state independent.
@@ -309,17 +357,21 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// user expectation ("I clicked replay, now show me the tour").
if (readAndClearForceLaunch(user.id)) {
setShowTour(true);
- } else if (!shouldAutoLaunchTour({
- onboardingTourCompleted: user.onboardingTourCompleted,
- onboardingCompletedAt: user.onboardingCompletedAt,
- nowMs: mountedAtMs,
- })) {
+ } else if (
+ !shouldAutoLaunchTour({
+ onboardingTourCompleted: user.onboardingTourCompleted,
+ onboardingCompletedAt: user.onboardingCompletedAt,
+ nowMs: mountedAtMs,
+ justFromWizard,
+ })
+ ) {
setShowTour(false);
} else if (readSessionDismissed(user.id)) {
setShowTour(false);
- } else if (readAndClearJustFromWizard(user.id)) {
- // Mark "deferred"; the effect below schedules a fixed-duration
- // timer + flips `showTour` from inside the timeout callback
+ } else if (justFromWizard) {
+ // Fresh from anamnesis — defer past the post-wizard grace so the
+ // green-tick toast doesn't double-overlay, then open. The effect
+ // below schedules the timer and flips `showTour` in its callback
// (which the set-state-in-effect rule allows).
setShowTour(false);
setDeferredFromWizard(true);
@@ -344,13 +396,15 @@ export function TourLauncher({ ready }: TourLauncherProps) {
return () => window.clearTimeout(id);
}, [deferredFromWizard, user?.id]);
- // Listen for explicit restart events from Settings → Account.
+ // Listen for explicit restart events from Settings → Advanced. A
+ // restart opens the FULL tour from the top (singleStop cleared).
// setState inside a window-event listener is fine under the
// set-state-in-effect rule.
const userIdForRestart = user?.id;
useEffect(() => {
function onRestart() {
if (userIdForRestart) clearSessionDismissed(userIdForRestart);
+ setSingleStop(null);
setShowTour(true);
}
window.addEventListener("healthlog:tour-restart", onRestart);
@@ -359,17 +413,48 @@ export function TourLauncher({ ready }: TourLauncherProps) {
};
}, [userIdForRestart]);
+ // v1.18.6 — per-module "Diese Tour zeigen" re-entry. A module page
+ // dispatches `healthlog:module-tour` with `{ stopId }`; the launcher
+ // opens the overlay narrowed to that single card on the current page
+ // (no cross-page navigation, no completion flip).
+ useEffect(() => {
+ function onModuleTour(e: Event) {
+ const detail = (e as CustomEvent<{ stopId?: string }>).detail;
+ if (!detail?.stopId) return;
+ setSingleStop(detail.stopId as TourStopId);
+ setShowTour(true);
+ }
+ window.addEventListener("healthlog:module-tour", onModuleTour);
+ return () => {
+ window.removeEventListener("healthlog:module-tour", onModuleTour);
+ };
+ }, []);
+
if (showTour !== true) return null;
- // v1.18.0 B5 — drop the achievements tour stop when the account has the
- // achievements module turned off. Reads the per-user module map the same
- // way every other client gate does (`modules?. !== false`,
- // default-on).
- const includeAchievements = tourIncludesAchievements(user?.modules);
+ // The resolved module map gates which stops appear (default-on) and
+ // keeps the "Schritt n/total" counter honest.
+ const modules = user?.modules;
+ // v1.18.6 — resume the full tour from the persisted checkpoint. The
+ // single-stop re-entry ignores the resume point (it opens its card).
+ const resumeFromStopId = singleStop
+ ? null
+ : (user?.onboardingTourProgress?.lastStopId ?? null);
return (
{
+ // Fire-and-forget checkpoint so a mid-tour reload resumes at the
+ // right module. Skipped implicitly by the overlay for the
+ // single-stop re-entry. A failed write is non-fatal — the resume
+ // point is a convenience, not a correctness invariant.
+ void apiPost("/api/onboarding/tour", {
+ progress: { ...progress, updatedAt: new Date().toISOString() },
+ }).catch(() => {});
+ }}
onClose={async (outcome) => {
// Optimistic: hide the overlay immediately, persist in the
// background. The flag is idempotent so re-fires are safe;
@@ -378,7 +463,13 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// for the rest of the session, and the next refetch of
// `/api/auth/me` (which the user gets on any nav) will
// surface any divergence.
+ const wasSingleStop = singleStop !== null;
setShowTour(false);
+ setSingleStop(null);
+ // A single-stop re-entry must NOT flip the global completion
+ // flag or write the session-dismiss guard — it's one card, not
+ // "the tour". Just close.
+ if (wasSingleStop) return;
if (user?.id) writeSessionDismissed(user.id);
try {
await apiPost("/api/onboarding/tour", { completed: true, outcome });
diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx
index ae350d47e..852828568 100644
--- a/src/components/onboarding/tour.tsx
+++ b/src/components/onboarding/tour.tsx
@@ -36,6 +36,7 @@
* `skipped` to the API + audit log).
*/
+import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -43,26 +44,46 @@ import { useTranslations } from "@/lib/i18n/context";
import {
buildTourStops,
currentStop,
+ deriveProgress,
initTourState,
isTourFinished,
nextStep,
prevStep,
skipTour,
stepCounter,
+ type TourModuleMap,
+ type TourState,
type TourStop,
+ type TourStopId,
} from "@/lib/onboarding/tour-state";
interface TourProps {
/**
- * Whether the achievements page exists and should be a tour stop.
- * The launcher passes `false` if B4 hasn't shipped to skip the
- * stop without renumbering.
+ * Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose
+ * `requiresModule` resolves to `false` are dropped; the counter total
+ * tracks the resolved list so "Schritt n/total" stays honest.
*/
- includeAchievements?: boolean;
+ modules?: TourModuleMap;
/**
- * Called when the tour finishes (Done OR Skip OR Esc OR Backdrop
- * click). Receives the terminal outcome so the launcher can audit
- * the difference.
+ * v1.18.6 — open at this stop id (the persisted resume point). When
+ * the id is absent from the resolved list the tour starts at the top.
+ */
+ resumeFromStopId?: string | null;
+ /**
+ * v1.18.6 — narrow the tour to a single module card (the per-page
+ * "Diese Tour zeigen" re-entry). When set, the overlay shows just
+ * that stop with a Done button and no cross-page navigation.
+ */
+ filterToStop?: TourStopId;
+ /**
+ * v1.18.6 — fire-and-forget progress checkpoint on every step change
+ * + terminal. The launcher PATCHes it so a reload resumes correctly.
+ * Not called for the single-stop re-entry (`filterToStop`).
+ */
+ onProgress?: (progress: ReturnType) => void;
+ /**
+ * Called when the tour finishes (Done OR Skip OR Esc). Receives the
+ * terminal outcome so the launcher can audit the difference.
*/
onClose: (outcome: "completed" | "skipped") => void;
}
@@ -216,56 +237,125 @@ const COLLISION_PAD = 8;
const SHEET_BREAKPOINT = 480;
export function OnboardingTour({
- includeAchievements = true,
+ modules,
+ resumeFromStopId,
+ filterToStop,
+ onProgress,
onClose,
}: TourProps) {
const { t } = useTranslations();
+ const router = useRouter();
+ const pathname = usePathname();
const stops = useMemo(
- () => buildTourStops({ includeAchievements }),
- [includeAchievements],
+ () => buildTourStops({ modules, filterToStop }),
+ [modules, filterToStop],
+ );
+ // Single-stop re-entry never resumes from a checkpoint — it opens on
+ // the one card the page asked for.
+ const [state, setState] = useState(() =>
+ initTourState(stops, filterToStop ? null : resumeFromStopId),
);
- const [state, setState] = useState(() => initTourState(stops));
const [rect, setRect] = useState(null);
const stop = currentStop(state);
- // Re-measure on step change, viewport resize, and scroll. We intentionally
- // *don't* MutationObserver the whole document — the targets are stable in
- // the layout and re-measuring on rAF after scroll/resize is plenty.
+ // v1.18.6 — cross-page step change. A stop on another route triggers a
+ // `router.push` first; the overlay survives the navigation because it is
+ // mounted at the app-shell level (not inside the dashboard). After the
+ // route settles we must wait for the new page's anchor to MOUNT before
+ // measuring — anchoring against a not-yet-rendered (0×0) target would snap
+ // the spotlight to centre-screen. We poll on rAF with a bounded retry, then
+ // give up to the centred fallback so the tour never blocks indefinitely.
+ //
+ // We intentionally *don't* MutationObserver the whole document — the
+ // targets are stable once mounted; bounded rAF polling on step change plus
+ // re-measuring on scroll/resize is plenty.
useEffect(() => {
if (!stop) return;
+ if (typeof document === "undefined") return;
+
let raf = 0;
+ let cancelled = false;
+ const reduceMotion =
+ typeof window !== "undefined" &&
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
+
+ // Navigate to the stop's route if we're not already there. Same-route
+ // stops (the two dashboard stops while on `/`, or the wrap-up which has
+ // no route) skip the push. `filterToStop` re-entry never navigates — it
+ // runs on the page the user is already viewing.
+ if (!filterToStop && stop.route && stop.route !== pathname) {
+ router.push(stop.route);
+ }
+
+ const measureNow = () => {
+ if (cancelled) return;
+ setRect(measureTarget(stop.targetId));
+ };
const measure = () => {
cancelAnimationFrame(raf);
- raf = requestAnimationFrame(() => {
- setRect(measureTarget(stop.targetId));
- });
+ raf = requestAnimationFrame(measureNow);
};
- // Bring the target into view BEFORE the first measurement of this
- // step — anchors low on the page (e.g. the Settings nav item) can
- // sit entirely below the fold, and positioning against an
- // off-screen rect put the popover under the viewport bottom where
- // its Next button was unreachable. `block: "center"` keeps room on
- // both sides for the flipped placement; the rAF in `measure()`
- // runs after the instant scroll so we measure post-scroll
- // coordinates. Optional-chained: jsdom and older engines lack it.
- if (stop.targetId && typeof document !== "undefined") {
- document
- .querySelector(
- `[data-tour-id="${CSS.escape(stop.targetId)}"]`,
- )
- ?.scrollIntoView?.({ block: "center", inline: "nearest" });
- }
- measure();
+
+ // Wait for the anchor to mount (after navigation it isn't in the DOM on
+ // the first frame). Bounded poll: ~40 frames (~650 ms) before falling
+ // back to the centred tooltip via `measureTarget` returning null.
+ let attempts = 0;
+ const MAX_ATTEMPTS = 40;
+ const settleAndMeasure = () => {
+ if (cancelled) return;
+ const el = stop.targetId
+ ? document.querySelector(
+ `[data-tour-id="${CSS.escape(stop.targetId)}"]`,
+ )
+ : null;
+ if (el) {
+ // Bring the anchor into view BEFORE measuring — anchors below the
+ // fold positioned the popover under the viewport bottom otherwise.
+ // Reduced-motion users get an instant jump, not a smooth scroll.
+ el.scrollIntoView?.({
+ block: "center",
+ inline: "nearest",
+ behavior: reduceMotion ? "auto" : "smooth",
+ });
+ measure();
+ return;
+ }
+ // Centred / route-less stop (wrap-up): measure immediately.
+ if (!stop.targetId) {
+ measureNow();
+ return;
+ }
+ attempts += 1;
+ if (attempts < MAX_ATTEMPTS) {
+ raf = requestAnimationFrame(settleAndMeasure);
+ } else {
+ // Anchor never mounted — centre the tooltip rather than block.
+ measureNow();
+ }
+ };
+ settleAndMeasure();
+
window.addEventListener("resize", measure);
window.addEventListener("scroll", measure, true);
return () => {
+ cancelled = true;
cancelAnimationFrame(raf);
window.removeEventListener("resize", measure);
window.removeEventListener("scroll", measure, true);
};
- }, [stop]);
+ // `pathname` is intentionally a dep: after the route settles the effect
+ // re-runs and the anchor poll finds the now-mounted target.
+ }, [stop, filterToStop, router, pathname]);
+
+ // v1.18.6 — fire-and-forget progress checkpoint on every step / terminal
+ // transition so a mid-tour reload resumes at the right module. Skipped for
+ // the single-stop re-entry (it isn't "the tour", just one card).
+ useEffect(() => {
+ if (filterToStop || !onProgress) return;
+ onProgress(deriveProgress(state));
+ }, [state, filterToStop, onProgress]);
// Focus management — moves focus to the primary action when the
// step changes so keyboard users can hit Enter to advance. The
diff --git a/src/lib/onboarding/__tests__/tour-state.test.ts b/src/lib/onboarding/__tests__/tour-state.test.ts
index 450d73ce2..b46b1c8d6 100644
--- a/src/lib/onboarding/__tests__/tour-state.test.ts
+++ b/src/lib/onboarding/__tests__/tour-state.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildTourStops,
currentStop,
+ deriveProgress,
initTourState,
isTourFinished,
nextStep,
@@ -11,48 +12,82 @@ import {
stepCounter,
} from "../tour-state";
+const FULL_ORDER = [
+ "dashboardOverview",
+ "quickAdd",
+ "measurements",
+ "medications",
+ "labs",
+ "illness",
+ "vorsorge",
+ "cycle",
+ "mood",
+ "insights",
+ "coach",
+ "integrations",
+ "export",
+ "achievements",
+ "wrapUp",
+];
+
describe("tour-state", () => {
describe("buildTourStops()", () => {
- it("includes the achievements stop by default (5 stops)", () => {
+ it("returns all 15 module stops in order with every module on", () => {
const stops = buildTourStops();
- expect(stops.map((s) => s.id)).toEqual([
- "tile-strip",
- "quick-add",
- "insights",
- "integrations",
- "achievements",
- ]);
+ expect(stops.map((s) => s.id)).toEqual(FULL_ORDER);
});
- it("omits the achievements stop when the flag is off (4 stops)", () => {
- const stops = buildTourStops({ includeAchievements: false });
- expect(stops.map((s) => s.id)).toEqual([
- "tile-strip",
- "quick-add",
- "insights",
- "integrations",
- ]);
+ it("drops a stop whose module resolves to false (default-on otherwise)", () => {
+ const stops = buildTourStops({
+ modules: { cycle: false, mood: false, achievements: false },
+ });
+ const ids = stops.map((s) => s.id);
+ expect(ids).not.toContain("cycle");
+ expect(ids).not.toContain("mood");
+ expect(ids).not.toContain("achievements");
+ // Core + un-disabled modules survive.
+ expect(ids).toContain("dashboardOverview");
+ expect(ids).toContain("labs");
+ expect(ids).toContain("wrapUp");
});
- it("every stop carries a non-empty i18n title and body key", () => {
- const stops = buildTourStops();
- for (const s of stops) {
+ it("keeps a module stop when its key is absent or true (fail-open)", () => {
+ const stops = buildTourStops({ modules: { labs: true } });
+ expect(stops.map((s) => s.id)).toContain("labs");
+ expect(stops.map((s) => s.id)).toContain("mood");
+ });
+
+ it("filterToStop narrows to a single module card", () => {
+ const stops = buildTourStops({ filterToStop: "labs" });
+ expect(stops.map((s) => s.id)).toEqual(["labs"]);
+ });
+
+ it("filterToStop yields nothing when the module is disabled", () => {
+ const stops = buildTourStops({
+ filterToStop: "cycle",
+ modules: { cycle: false },
+ });
+ expect(stops).toEqual([]);
+ });
+
+ it("every stop carries a distinct, namespaced i18n title and body key", () => {
+ for (const s of buildTourStops()) {
expect(s.titleKey).toMatch(/^onboarding\.tour\.steps\./);
expect(s.bodyKey).toMatch(/^onboarding\.tour\.steps\./);
expect(s.titleKey).not.toBe(s.bodyKey);
}
});
- it("every stop declares a stable targetId for the data-tour-id contract", () => {
- const stops = buildTourStops();
- for (const s of stops) {
- // `null` is allowed (centred-screen tooltip without a
- // spotlight cutout) but we currently use real targets for
- // every stop — a regression here would mean the spotlight
- // silently falls back to centre-screen.
- expect(typeof s.targetId === "string" && s.targetId.length > 0).toBe(
- true,
- );
+ it("every cross-page stop declares a route; the wrap-up is centred + routeless", () => {
+ for (const s of buildTourStops()) {
+ if (s.id === "wrapUp") {
+ expect(s.targetId).toBeNull();
+ expect(s.route).toBeUndefined();
+ expect(s.placement).toBe("center");
+ } else {
+ expect(typeof s.route).toBe("string");
+ expect(s.targetId).toBeTruthy();
+ }
}
});
});
@@ -62,76 +97,97 @@ describe("tour-state", () => {
const state = initTourState(buildTourStops());
expect(state.index).toBe(0);
expect(state.outcome).toBeNull();
- expect(isTourFinished(state)).toBe(false);
- expect(currentStop(state)?.id).toBe("tile-strip");
+ expect(currentStop(state)?.id).toBe("dashboardOverview");
+ });
+
+ it("resumes from a persisted stop id", () => {
+ const state = initTourState(buildTourStops(), "labs");
+ expect(currentStop(state)?.id).toBe("labs");
+ });
+
+ it("ignores a resume id absent from the resolved list", () => {
+ const state = initTourState(
+ buildTourStops({ modules: { cycle: false } }),
+ "cycle",
+ );
+ expect(state.index).toBe(0);
});
it("nextStep advances through the list", () => {
let state = initTourState(buildTourStops());
state = nextStep(state);
- expect(currentStop(state)?.id).toBe("quick-add");
- state = nextStep(state);
- expect(currentStop(state)?.id).toBe("insights");
+ expect(currentStop(state)?.id).toBe("quickAdd");
});
- it("nextStep on the last step finishes the tour with outcome=completed", () => {
+ it("nextStep on the last step finishes with outcome=completed", () => {
const stops = buildTourStops();
let state = initTourState(stops);
- // step through every stop
- for (let i = 0; i < stops.length; i++) {
- state = nextStep(state);
- }
+ for (let i = 0; i < stops.length; i++) state = nextStep(state);
expect(state.outcome).toBe("completed");
expect(isTourFinished(state)).toBe(true);
expect(currentStop(state)).toBeNull();
});
- it("prevStep is pinned at index 0 (LeftArrow on first step is a no-op)", () => {
+ it("prevStep is pinned at index 0", () => {
const state = initTourState(buildTourStops());
- const back = prevStep(state);
- expect(back.index).toBe(0);
- expect(back.outcome).toBeNull();
- });
-
- it("prevStep walks backward when not at the start", () => {
- let state = initTourState(buildTourStops());
- state = nextStep(state);
- state = nextStep(state);
- expect(state.index).toBe(2);
- const back = prevStep(state);
- expect(back.index).toBe(1);
+ expect(prevStep(state).index).toBe(0);
});
it("skipTour from any step finishes with outcome=skipped", () => {
let state = initTourState(buildTourStops());
- state = nextStep(state); // on step 2 of 5
+ state = nextStep(state);
const skipped = skipTour(state);
expect(skipped.outcome).toBe("skipped");
- expect(isTourFinished(skipped)).toBe(true);
expect(currentStop(skipped)).toBeNull();
});
-
- it("nextStep / prevStep / skipTour are no-ops once finished", () => {
- let state = initTourState(buildTourStops());
- state = skipTour(state);
- const after = nextStep(prevStep(skipTour(state)));
- expect(after).toEqual(state);
- });
});
describe("stepCounter", () => {
- it("reports 1-based current and the total step count", () => {
+ it("reports 1-based current and the resolved total", () => {
const stops = buildTourStops();
let state = initTourState(stops);
expect(stepCounter(state)).toEqual({ current: 1, total: stops.length });
state = nextStep(state);
- expect(stepCounter(state)).toEqual({ current: 2, total: stops.length });
+ expect(stepCounter(state).current).toBe(2);
});
- it("clamps `current` to total when the tour is finished", () => {
- let state = initTourState(buildTourStops({ includeAchievements: false }));
+ it("total tracks the gated list so the counter stays honest", () => {
+ const stops = buildTourStops({ modules: { cycle: false, mood: false } });
+ const state = initTourState(stops);
+ expect(stepCounter(state).total).toBe(stops.length);
+ expect(stops.length).toBe(13);
+ });
+ });
+
+ describe("deriveProgress", () => {
+ it("reports the current stop as the resume point while running", () => {
+ let state = initTourState(buildTourStops());
+ state = nextStep(state);
+ state = nextStep(state);
+ const p = deriveProgress(state);
+ expect(p.lastStopId).toBe("measurements");
+ expect(p.status).toBe("in_progress");
+ expect(p.completedStopIds).toEqual([
+ "dashboardOverview",
+ "quickAdd",
+ "measurements",
+ ]);
+ });
+
+ it("marks completed with every stop reached and a null resume point", () => {
+ const stops = buildTourStops();
+ let state = initTourState(stops);
+ for (let i = 0; i < stops.length; i++) state = nextStep(state);
+ const p = deriveProgress(state);
+ expect(p.status).toBe("completed");
+ expect(p.lastStopId).toBeNull();
+ expect(p.completedStopIds).toHaveLength(stops.length);
+ });
+
+ it("marks skipped", () => {
+ let state = initTourState(buildTourStops());
state = skipTour(state);
- expect(stepCounter(state)).toEqual({ current: 4, total: 4 });
+ expect(deriveProgress(state).status).toBe("skipped");
});
});
});
diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts
index a8776cfc5..adff4cd9f 100644
--- a/src/lib/onboarding/tour-state.ts
+++ b/src/lib/onboarding/tour-state.ts
@@ -1,114 +1,245 @@
/**
* v1.4.15 Phase B5 — onboarding tour state model.
+ * v1.18.6 — rebuilt as a 15-stop module-by-module guided tour.
*
- * Pure data + state-machine helpers for the spotlight tour overlaid on
- * the dashboard for first-time users. Kept DOM-free so vitest (Node
- * environment, no jsdom) can exercise the navigation logic directly,
- * matching the project test-style elsewhere.
+ * Pure data + state-machine helpers for the spotlight tour. Kept
+ * DOM-free so vitest (Node environment, no jsdom) can exercise the
+ * navigation logic directly.
*
- * The UI layer (`src/components/onboarding/tour.tsx`) builds the
- * step list once on mount and then drives navigation through these
- * helpers.
+ * The tour walks every module in the order data → intelligence →
+ * export, teaching each module's value, one hidden capability, and how
+ * it feeds the others. The UI layer (`src/components/onboarding/tour.tsx`)
+ * builds the step list once on mount and then drives navigation through
+ * these helpers.
*
- * Tour stops are anchored to UI targets via `data-tour-id` attributes
- * (NOT class-name selectors — class names change too often during
- * Tailwind tweaks; `data-tour-id` is a stable contract). Every stop
- * declares its `targetId`; the spotlight component looks up the live
- * element via `document.querySelector('[data-tour-id=""]')`.
- *
- * If a target element is missing at runtime (e.g. user is on a
- * resized layout that hides it), the spotlight falls back to a
- * centred-screen tooltip so the tour never blocks indefinitely.
+ * Stops anchor to UI targets via `data-tour-id` attributes (NOT
+ * class-name selectors — class names churn during Tailwind tweaks;
+ * `data-tour-id` is a stable contract). A stop on another route also
+ * carries a `route`; the overlay `router.push`es there, waits for the
+ * anchor to mount, then measures. A missing target at runtime falls
+ * back to a centred-screen tooltip so the tour never blocks.
*/
-/** Stable IDs for every stop in the tour. */
+import type { ModuleKey } from "@/lib/modules/registry";
+
+/** Stable IDs for every stop in the tour, in presentation order. */
export type TourStopId =
- | "tile-strip"
- | "quick-add"
+ | "dashboardOverview"
+ | "quickAdd"
+ | "measurements"
+ | "medications"
+ | "labs"
+ | "illness"
+ | "vorsorge"
+ | "cycle"
+ | "mood"
| "insights"
+ | "coach"
| "integrations"
- | "achievements";
+ | "export"
+ | "achievements"
+ | "wrapUp";
/**
* One step in the tour. The `targetId` matches a `data-tour-id` on a
- * live element somewhere in the app; the title/body i18n keys live
- * under `onboarding.tour.steps..*`.
+ * live element; the title/body i18n keys live under
+ * `onboarding.tour.steps..*`.
*/
export interface TourStop {
id: TourStopId;
/**
* The `data-tour-id` value of the element to anchor the spotlight
- * to. May be `null` if the stop is purely informational and should
- * render in a centred-screen tooltip without a spotlight cutout.
+ * to. `null` for a purely informational centred stop (the wrap-up).
*/
targetId: string | null;
/**
- * Preferred placement of the tooltip relative to the target. The UI
- * layer is allowed to flip this if the target is too close to a
- * viewport edge.
+ * Preferred tooltip placement relative to the target. The UI layer
+ * may flip it if the target is too close to a viewport edge.
*/
placement: "top" | "bottom" | "left" | "right" | "center";
/** i18n key for the step title. */
titleKey: string;
/** i18n key for the step body. */
bodyKey: string;
+ /**
+ * v1.18.6 — the route this stop's anchor lives on. Absent ⇒ the
+ * anchor is on the current page (the wrap-up centred stop). When set,
+ * the overlay navigates there before measuring. `"/"` is explicit for
+ * the two dashboard stops so the resume path can route back from any
+ * page.
+ */
+ route?: string;
+ /**
+ * v1.18.6 — module gate. When set, the stop is dropped unless the
+ * account's resolved module map has the key enabled (default-on:
+ * only an explicit `false` drops it, mirroring the nav gate).
+ */
+ requiresModule?: ModuleKey;
}
/**
- * Build the canonical tour-step list. Caller may inject a feature
- * flag — e.g. `includeAchievements: false` if B4 hasn't shipped yet
- * — and we omit that stop without renumbering.
+ * v1.18.6 — the canonical 15-stop module tour. Each entry is a literal
+ * so the order, anchors, routes and gates are one auditable list.
+ */
+const ALL_STOPS: readonly TourStop[] = [
+ {
+ id: "dashboardOverview",
+ targetId: "dashboard-tile-strip",
+ placement: "bottom",
+ route: "/",
+ titleKey: "onboarding.tour.steps.dashboardOverview.title",
+ bodyKey: "onboarding.tour.steps.dashboardOverview.body",
+ },
+ {
+ id: "quickAdd",
+ targetId: "dashboard-quick-add",
+ placement: "bottom",
+ route: "/",
+ titleKey: "onboarding.tour.steps.quickAdd.title",
+ bodyKey: "onboarding.tour.steps.quickAdd.body",
+ },
+ {
+ id: "measurements",
+ targetId: "measurements-hero",
+ placement: "bottom",
+ route: "/measurements",
+ titleKey: "onboarding.tour.steps.measurements.title",
+ bodyKey: "onboarding.tour.steps.measurements.body",
+ },
+ {
+ id: "medications",
+ targetId: "medications-hero",
+ placement: "bottom",
+ route: "/medications",
+ requiresModule: "medications",
+ titleKey: "onboarding.tour.steps.medications.title",
+ bodyKey: "onboarding.tour.steps.medications.body",
+ },
+ {
+ id: "labs",
+ targetId: "labs-hero",
+ placement: "bottom",
+ route: "/labs",
+ requiresModule: "labs",
+ titleKey: "onboarding.tour.steps.labs.title",
+ bodyKey: "onboarding.tour.steps.labs.body",
+ },
+ {
+ id: "illness",
+ targetId: "illness-hero",
+ placement: "bottom",
+ route: "/illness",
+ requiresModule: "illness",
+ titleKey: "onboarding.tour.steps.illness.title",
+ bodyKey: "onboarding.tour.steps.illness.body",
+ },
+ {
+ id: "vorsorge",
+ targetId: "vorsorge-hero",
+ placement: "bottom",
+ route: "/vorsorge",
+ titleKey: "onboarding.tour.steps.vorsorge.title",
+ bodyKey: "onboarding.tour.steps.vorsorge.body",
+ },
+ {
+ id: "cycle",
+ targetId: "cycle-hero",
+ placement: "bottom",
+ route: "/cycle",
+ requiresModule: "cycle",
+ titleKey: "onboarding.tour.steps.cycle.title",
+ bodyKey: "onboarding.tour.steps.cycle.body",
+ },
+ {
+ id: "mood",
+ targetId: "mood-hero",
+ placement: "bottom",
+ route: "/mood",
+ requiresModule: "mood",
+ titleKey: "onboarding.tour.steps.mood.title",
+ bodyKey: "onboarding.tour.steps.mood.body",
+ },
+ {
+ id: "insights",
+ targetId: "insights-hero",
+ placement: "bottom",
+ route: "/insights",
+ requiresModule: "insights",
+ titleKey: "onboarding.tour.steps.insights.title",
+ bodyKey: "onboarding.tour.steps.insights.body",
+ },
+ {
+ id: "coach",
+ targetId: "coach-hero",
+ placement: "bottom",
+ route: "/coach",
+ requiresModule: "coach",
+ titleKey: "onboarding.tour.steps.coach.title",
+ bodyKey: "onboarding.tour.steps.coach.body",
+ },
+ {
+ id: "integrations",
+ targetId: "integrations-hero",
+ placement: "bottom",
+ route: "/settings/integrations",
+ titleKey: "onboarding.tour.steps.integrations.title",
+ bodyKey: "onboarding.tour.steps.integrations.body",
+ },
+ {
+ id: "export",
+ targetId: "export-hero",
+ placement: "bottom",
+ route: "/settings/advanced",
+ titleKey: "onboarding.tour.steps.export.title",
+ bodyKey: "onboarding.tour.steps.export.body",
+ },
+ {
+ id: "achievements",
+ targetId: "achievements-hero",
+ placement: "bottom",
+ route: "/achievements",
+ requiresModule: "achievements",
+ titleKey: "onboarding.tour.steps.achievements.title",
+ bodyKey: "onboarding.tour.steps.achievements.body",
+ },
+ {
+ id: "wrapUp",
+ targetId: null,
+ placement: "center",
+ titleKey: "onboarding.tour.steps.wrapUp.title",
+ bodyKey: "onboarding.tour.steps.wrapUp.body",
+ },
+];
+
+/**
+ * A partial map of `ModuleKey → enabled` — the `modules` field from
+ * `GET /api/auth/me`. Default-on: a missing key or `true` keeps the
+ * stop; only an explicit `false` drops it.
+ */
+export type TourModuleMap = Partial>;
+
+/**
+ * Build the resolved tour-step list for the given module map. Stops
+ * whose `requiresModule` resolves to `false` are dropped without
+ * renumbering the rest.
+ *
+ * `filterToStop` narrows the list to a single stop — the per-module
+ * "Diese Tour zeigen" re-entry shows just that module's card. An
+ * unknown / module-disabled stop id yields an empty list (caller
+ * renders nothing).
*/
export function buildTourStops(opts?: {
- includeAchievements?: boolean;
+ modules?: TourModuleMap;
+ filterToStop?: TourStopId;
}): TourStop[] {
- const includeAchievements = opts?.includeAchievements ?? true;
-
- const stops: TourStop[] = [
- {
- id: "tile-strip",
- targetId: "dashboard-tile-strip",
- placement: "bottom",
- titleKey: "onboarding.tour.steps.tileStrip.title",
- bodyKey: "onboarding.tour.steps.tileStrip.body",
- },
- {
- id: "quick-add",
- targetId: "dashboard-quick-add",
- placement: "bottom",
- titleKey: "onboarding.tour.steps.quickAdd.title",
- bodyKey: "onboarding.tour.steps.quickAdd.body",
- },
- {
- id: "insights",
- targetId: "nav-insights",
- placement: "right",
- titleKey: "onboarding.tour.steps.insights.title",
- bodyKey: "onboarding.tour.steps.insights.body",
- },
- {
- id: "integrations",
- // The integrations link sits behind the Settings sub-menu on
- // some viewports — we anchor to the Settings nav item instead
- // so the spotlight is always visible without forcing a hover.
- targetId: "nav-settings",
- placement: "right",
- titleKey: "onboarding.tour.steps.integrations.title",
- bodyKey: "onboarding.tour.steps.integrations.body",
- },
- ];
-
- if (includeAchievements) {
- stops.push({
- id: "achievements",
- targetId: "nav-achievements",
- placement: "right",
- titleKey: "onboarding.tour.steps.achievements.title",
- bodyKey: "onboarding.tour.steps.achievements.body",
- });
+ const modules = opts?.modules;
+ const gated = ALL_STOPS.filter(
+ (s) => !s.requiresModule || modules?.[s.requiresModule] !== false,
+ );
+ if (opts?.filterToStop) {
+ return gated.filter((s) => s.id === opts.filterToStop);
}
-
- return stops;
+ return gated;
}
/**
@@ -116,29 +247,39 @@ export function buildTourStops(opts?: {
* helper returns a fresh state instead of mutating in place.
*/
export interface TourState {
- /** Index into the steps array. May equal `steps.length` to indicate the tour is complete. */
+ /** Index into the steps array. May equal `steps.length` when complete. */
index: number;
/** Cached step list — handy for the renderer. */
steps: TourStop[];
/**
- * `null` while the tour is still running, `"completed"` once the
- * user reached the end and clicked Done, `"skipped"` if they hit
- * Skip on any step. Both terminal states cause the same DB write
- * (`onboardingTourCompleted = true`); we keep them distinct purely
- * so analytics can tell them apart in audit logs.
+ * `null` while running, `"completed"` once the user reached the end
+ * and clicked Done, `"skipped"` if they hit Skip on any step.
*/
outcome: "completed" | "skipped" | null;
}
-/** Initial state at the first step. */
-export function initTourState(steps: TourStop[]): TourState {
- return { index: 0, steps, outcome: null };
+/**
+ * Initial state. `resumeFromStopId` (v1.18.6) seeds the index from a
+ * persisted resume point — if the id is present in the resolved list,
+ * the tour opens there; otherwise it starts at the first stop (e.g. the
+ * resume stop's module was disabled since the checkpoint was written).
+ */
+export function initTourState(
+ steps: TourStop[],
+ resumeFromStopId?: string | null,
+): TourState {
+ let index = 0;
+ if (resumeFromStopId) {
+ const found = steps.findIndex((s) => s.id === resumeFromStopId);
+ if (found >= 0) index = found;
+ }
+ return { index, steps, outcome: null };
}
/**
- * Advance to the next step. If we're already on the last step, mark
- * the tour `completed`. Calling on a terminal state is a no-op so
- * the renderer can fire-and-forget on rapid double-clicks.
+ * Advance to the next step. On the last step, mark `completed`. A
+ * no-op on a terminal state so the renderer can fire-and-forget on
+ * rapid double-clicks.
*/
export function nextStep(state: TourState): TourState {
if (state.outcome !== null) return state;
@@ -151,8 +292,7 @@ export function nextStep(state: TourState): TourState {
/**
* Step backwards. Pinned at index 0 — the first step has no Back
- * button visually; this helper just no-ops to keep the keyboard
- * shortcut (LeftArrow) safe.
+ * button visually; this keeps the LeftArrow shortcut safe.
*/
export function prevStep(state: TourState): TourState {
if (state.outcome !== null) return state;
@@ -187,3 +327,34 @@ export function stepCounter(state: TourState): {
total: state.steps.length,
};
}
+
+/**
+ * v1.18.6 — derive the persisted progress checkpoint from the live
+ * navigation state. Pure so the overlay can fire-and-forget it without
+ * touching the DOM. `completedStopIds` accumulates every stop the user
+ * has reached up to (and including) the current index; `lastStopId` is
+ * the resume point.
+ */
+export function deriveProgress(state: TourState): {
+ lastStopId: string | null;
+ completedStopIds: string[];
+ status: "in_progress" | "completed" | "skipped";
+} {
+ const status =
+ state.outcome === "completed"
+ ? "completed"
+ : state.outcome === "skipped"
+ ? "skipped"
+ : "in_progress";
+ const reachedCount =
+ state.outcome !== null
+ ? state.steps.length
+ : Math.min(state.index + 1, state.steps.length);
+ const completedStopIds = state.steps.slice(0, reachedCount).map((s) => s.id);
+ const current = currentStop(state);
+ return {
+ lastStopId: current?.id ?? null,
+ completedStopIds,
+ status,
+ };
+}