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, + }; +}