feat(onboarding): rebuild the tour as a 15-stop cross-page module walk

Replace the five chrome-pointer stops with a module-by-module guided tour
in the data->intelligence->export order. Each stop teaches the module's
value, one hidden capability, and how it feeds the others. Stops carry a
route + module gate; the overlay router.pushes to the route, waits for the
anchor to mount, then measures, so a single tour walks the whole product.

Move the launcher to the app shell so the overlay survives navigation,
auto-open immediately after anamnesis for fresh users (24h path stays the
fallback for wizard-skippers), resume from the persisted checkpoint, and
gate stops + the step counter on the resolved module map. Add tour copy
for all 15 stops across the six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 19:39:10 +02:00
1 parent 59e7b33285
commit f0c25cc275
14 files changed
+1008 -337

No files matched your search

+56 -15
View File
@@ -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",
+56 -15
View File
@@ -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",
+56 -15
View File
@@ -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",
+56 -15
View File
@@ -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",
+56 -15
View File
@@ -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",
+56 -15
View File
@@ -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",
+4 -10
View File
@@ -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. */}
<TourLauncher ready={data !== undefined} />
{/* 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 `<md`, centred Dialog on `md+`. */}
<QuickEntrySheets
+8
View File
@@ -7,6 +7,7 @@ import { AchievementUnlockNotifier } from "@/components/gamification/achievement
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { useTranslations } from "@/lib/i18n/context";
@@ -280,6 +281,13 @@ export function AuthShell({
<BottomNav />
</div>
<LayoutCoachMount />
{/* 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 && <TourLauncher />}
{/* 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
+14
View File
@@ -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 `<TourLauncher>` 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 (
<section
@@ -2,23 +2,27 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// vitest runs in the Node environment (no jsdom). The tour's interactive
// behaviour (keyboard navigation, focus management, target measurement)
// is exercised by the pure state machine at
// behaviour (keyboard navigation, focus management, target measurement,
// cross-page navigation) is exercised by the pure state machine at
// `src/lib/onboarding/__tests__/tour-state.test.ts` and by the e2e
// suite at `e2e/onboarding-tour.spec.ts`. Here we lock the SSR contract:
// initial render shape, ARIA wiring, i18n keys, and that the first
// step is the tile strip.
// initial render shape, ARIA wiring, i18n keys, and that the first step
// is the dashboard overview.
//
// The overlay reads Next's navigation hooks for the cross-page push; we
// stub them so the static render resolves.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/",
}));
import { I18nProvider } from "@/lib/i18n/context";
import { OnboardingTour } from "../tour";
function renderTour(includeAchievements = true) {
function renderTour(modules?: Record<string, boolean>) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OnboardingTour
includeAchievements={includeAchievements}
onClose={vi.fn()}
/>
<OnboardingTour modules={modules} onClose={vi.fn()} />
</I18nProvider>,
);
}
@@ -31,25 +35,23 @@ describe("<OnboardingTour>", () => {
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("<OnboardingTour>", () => {
<OnboardingTour onClose={vi.fn()} />
</I18nProvider>,
);
expect(html).toContain("Schritt 1 von 5");
expect(html).toContain("Deine Tageswerte");
expect(html).toContain("Schritt 1 von 15");
expect(html).toContain("Tour überspringen");
});
});
+114 -23
View File
@@ -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<boolean | null>(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<TourStopId | null>(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?.<key> !== 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 (
<OnboardingTour
includeAchievements={includeAchievements}
modules={modules}
resumeFromStopId={resumeFromStopId}
filterToStop={singleStop ?? undefined}
onProgress={(progress) => {
// 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 });
+124 -34
View File
@@ -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<typeof deriveProgress>) => 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<TourState>(() =>
initTourState(stops, filterToStop ? null : resumeFromStopId),
);
const [state, setState] = useState(() => initTourState(stops));
const [rect, setRect] = useState<SpotlightRect | null>(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<HTMLElement>(
`[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<HTMLElement>(
`[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
+122 -66
View File
@@ -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");
});
});
});
+262 -91
View File
@@ -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="<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.<id>.*`.
* live element; the title/body i18n keys live under
* `onboarding.tour.steps.<id>.*`.
*/
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<Record<ModuleKey, boolean>>;
/**
* 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,
};
}