merge(W4): module-by-module guided tour

Extend the in-house spotlight into a cross-page tour: 15 module stops,
each gated on its module, walking the app and teaching what each module
does and how it feeds the others. Auto-starts after the anamnesis,
resumes from onboarding_tour_progress_json, re-enters per-module via a
header trigger, and replays from one Settings card. Adds the tour
progress pref + migration 0173 and the onboarding tour checkpoint route.

# Conflicts:
#	src/components/onboarding/DoneScreen.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 19:55:00 +02:00
commit 1d83e10764
46 files changed
+1659 -564

No files matched your search

+139
View File
@@ -6749,6 +6749,30 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/onboarding/tour:
post:
tags:
- Onboarding
summary: Update module-tour completion + resume point
description: Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a
reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends.
`completed:false` is a replay reset that also clears the resume point.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TourUpdateRequest"
responses:
"200":
description: Tour state updated.
content:
application/json:
schema:
$ref: "#/components/schemas/TourUpdateEnvelope"
"401": *a1
"422": *a3
"429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -8079,6 +8103,55 @@ components:
type: string
required:
- ids
TourUpdateRequest:
type: object
properties:
completed:
description: Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point.
type: boolean
outcome:
description: Informational — distinguishes reaching the end from a skip.
type: string
enum:
- completed
- skipped
progress:
description: Mid-tour resume checkpoint. May arrive alone or with `completed`.
$ref: "#/components/schemas/TourProgress"
description: Update the module-tour state. Provide `completed` and/or `progress`.
TourProgress:
type: object
properties:
lastStopId:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
completedStopIds:
default: []
maxItems: 64
type: array
items:
type: string
minLength: 1
maxLength: 64
status:
type: string
enum:
- in_progress
- completed
- skipped
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
required:
- lastStopId
- status
- updatedAt
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
state.
MedicationTreatmentClass:
type: string
enum:
@@ -19363,6 +19436,37 @@ components:
description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally,
and a per-type breakdown. Retrospective only."
TourUpdateEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/TourUpdateResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
TourUpdateResponse:
type: object
properties:
onboardingTourCompleted:
type: boolean
progress:
anyOf:
- $ref: "#/components/schemas/TourProgressOutput"
- type: "null"
required:
- onboardingTourCompleted
- progress
additionalProperties: false
description: The persisted completion flag and resume point after the write.
MedicationScheduleOutput:
type: object
properties:
@@ -19634,6 +19738,41 @@ components:
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
future major version."
TourProgressOutput:
type: object
properties:
lastStopId:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
completedStopIds:
default: []
maxItems: 64
type: array
items:
type: string
minLength: 1
maxLength: 64
status:
type: string
enum:
- in_progress
- completed
- skipped
updatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
required:
- lastStopId
- completedStopIds
- status
- updatedAt
additionalProperties: false
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
state.
Medication:
type: object
properties:
+57 -18
View File
@@ -3533,31 +3533,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",
@@ -4469,9 +4510,7 @@
"changelog": "Changelog",
"docs": "Dokumentation",
"linksHeading": "Quellen & Dokumentation",
"newerAvailable": "Neue Version: {tag}",
"tourReplay": "Tour ansehen",
"tourReplayHint": "Zeige die Dashboard-Tour — praktisch, falls du sie beim ersten Besuch übersprungen hast."
"newerAvailable": "Neue Version: {tag}"
},
"testConnection": {
"test": "Verbindung testen",
+57 -18
View File
@@ -3533,31 +3533,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",
@@ -4469,9 +4510,7 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
"newerAvailable": "Newer version: {tag}"
},
"testConnection": {
"test": "Test connection",
+57 -18
View File
@@ -3533,31 +3533,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",
@@ -4469,9 +4510,7 @@
"changelog": "Changelog",
"docs": "Documentación",
"linksHeading": "Fuentes y documentación",
"newerAvailable": "Nueva versión: {tag}",
"tourReplay": "Volver a ver el recorrido",
"tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita."
"newerAvailable": "Nueva versión: {tag}"
},
"testConnection": {
"test": "Probar conexión",
+57 -18
View File
@@ -3533,31 +3533,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",
@@ -4469,9 +4510,7 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources et documentation",
"newerAvailable": "Nouvelle version : {tag}",
"tourReplay": "Revoir la visite",
"tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite."
"newerAvailable": "Nouvelle version : {tag}"
},
"testConnection": {
"test": "Tester la connexion",
+57 -18
View File
@@ -3533,31 +3533,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",
@@ -4469,9 +4510,7 @@
"changelog": "Changelog",
"docs": "Documentazione",
"linksHeading": "Fonti e documentazione",
"newerAvailable": "Nuova versione: {tag}",
"tourReplay": "Rivedi il tour",
"tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita."
"newerAvailable": "Nuova versione: {tag}"
},
"testConnection": {
"test": "Prova connessione",
+57 -18
View File
@@ -3533,31 +3533,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",
@@ -4469,9 +4510,7 @@
"changelog": "Changelog",
"docs": "Dokumentacja",
"linksHeading": "Źródła i dokumentacja",
"newerAvailable": "Nowa wersja: {tag}",
"tourReplay": "Obejrzyj samouczek",
"tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie."
"newerAvailable": "Nowa wersja: {tag}"
},
"testConnection": {
"test": "Testuj połączenie",
@@ -0,0 +1,22 @@
-- v1.18.6 — resumable module-tour progress.
--
-- A single nullable JSONB column on `users` carrying the fine-grained
-- resume point for the module-by-module guided tour. Follows the
-- established per-user JSON-column convention (`dashboard_widgets_json`,
-- `insights_layout_json`, `coach_prefs_json`).
--
-- Shape (validated by `src/lib/onboarding/tour-progress.ts`):
-- { "lastStopId": string | null,
-- "completedStopIds": string[],
-- "status": "in_progress" | "completed" | "skipped",
-- "updatedAt": string (ISO 8601) }
--
-- NULL = the user has not started the module tour. The coarse
-- `onboarding_tour_completed` boolean stays the launcher's auto-launch
-- gate; this column is purely the resume point and is cleared back to
-- NULL on a Settings replay so the tour genuinely restarts.
--
-- Additive + nullable: every existing account keeps NULL and the
-- application resolver treats NULL as "not started".
ALTER TABLE "users" ADD COLUMN "onboarding_tour_progress_json" JSONB;
+16
View File
@@ -256,6 +256,22 @@ model User {
// Settings → Account.
onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed")
// v1.18.6 — resumable module-tour progress. The coarse boolean above
// stays as the "did the user ever finish/dismiss" flag the launcher
// gates on; this column carries the fine-grained resume point so a
// mid-tour reload reopens at the right module. Follows the per-user
// JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson`
// / `coachPrefsJson`). Shape (validated by
// `src/lib/onboarding/tour-progress.ts`):
// { lastStopId: string | null,
// completedStopIds: string[],
// status: "in_progress" | "completed" | "skipped",
// updatedAt: string (ISO) }
// Null = the user has not started the module tour (fresh account or a
// legacy account that only carries the boolean flag). Cleared back to
// null on a Settings replay so the tour genuinely restarts.
onboardingTourProgressJson Json? @map("onboarding_tour_progress_json")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
+16 -7
View File
@@ -34,6 +34,7 @@ import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import {
@@ -317,13 +318,21 @@ export default function AchievementsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("achievements.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("achievements.subtitle")}
</p>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
data-tour-id="achievements-hero"
className="text-2xl font-bold tracking-tight"
>
{t("achievements.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("achievements.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="achievements" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+3 -3
View File
@@ -194,9 +194,9 @@ export function AdminSectionRenderer({
// v1.4.36 W4e — About section reused as-is from the settings
// surface. The component owns its own heading + cards layout
// so no SectionFrame wrapper.
// v1.18.1 E5 — hide the onboarding "Replay tour" card on the admin
// surface; it's a user-facing affordance with no admin-side use.
return <AboutSection hideTourReplay />;
// v1.18.6 — the About surface no longer carries the tour-replay card
// (consolidated onto Settings → Advanced), so there's nothing to hide.
return <AboutSection />;
default:
slug satisfies never;
return null;
+6
View File
@@ -10,6 +10,7 @@ import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
export const dynamic = "force-dynamic";
@@ -70,6 +71,11 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.18.6 — resumable module-tour progress. Null when the user has
// not started the tour; otherwise the resume point the launcher
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
// null ("start from the top") rather than 500-ing the /me read.
onboardingTourProgress: parseTourProgress(user.onboardingTourProgressJson),
// v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the
// URL is relative so PWA + native clients render identically
// and the `?v={updatedAtMs}` suffix busts the browser cache on
+50 -17
View File
@@ -5,30 +5,45 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
import { Prisma } from "@/generated/prisma/client";
/**
* v1.4.15 Phase B5 — onboarding tour completion endpoint.
* v1.18.6 — extended with the resumable module-tour progress point.
*
* Two operations on a single resource:
* Operations on a single resource:
*
* POST { completed: true, outcome: "completed" | "skipped" }
* → flips `users.onboarding_tour_completed` to true. Auto-fires
* when the user reaches the end of the spotlight tour OR
* explicitly dismisses it.
* when the user reaches the end of the module tour OR explicitly
* dismisses it.
*
* POST { completed: false }
* → resets the flag so the user can replay the tour from
* Settings → Account → "Restart onboarding tour".
* Settings → Advanced → "Modul-Tour neu starten". Also CLEARS
* `onboarding_tour_progress_json` back to null so the replay
* genuinely starts from the first module.
*
* The `outcome` field is informational — it lands in the structured
* Wide Event annotation so analytics can tell completion from
* dismiss without writing two columns. Same DB write either way.
* POST { progress: { lastStopId, completedStopIds, status, updatedAt } }
* → fire-and-forget mid-tour checkpoint so a reload resumes at the
* right module. May arrive WITH `completed` (the terminal
* checkpoint sends both) or alone (every `nextStep`).
*
* `outcome` is informational — it lands in the structured Wide Event
* annotation so analytics can tell completion from dismiss without a
* second column. Same DB write either way.
*/
const tourBodySchema = z.object({
completed: z.boolean(),
outcome: z.enum(["completed", "skipped"]).optional(),
});
const tourBodySchema = z
.object({
completed: z.boolean().optional(),
outcome: z.enum(["completed", "skipped"]).optional(),
progress: tourProgressSchema.optional(),
})
.refine((b) => b.completed !== undefined || b.progress !== undefined, {
message: "Provide `completed` and/or `progress`.",
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -43,18 +58,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid input", 422);
}
await prisma.user.update({
where: { id: user.id },
data: { onboardingTourCompleted: parsed.data.completed },
});
// Build the update field-by-field (no mass assignment). `completed`
// drives the coarse boolean; a replay (`completed:false`) wipes the
// resume point; a progress checkpoint writes the JSON column.
const data: Prisma.UserUpdateInput = {};
if (parsed.data.completed !== undefined) {
data.onboardingTourCompleted = parsed.data.completed;
if (parsed.data.completed === false) {
// Replay — start the resume point over.
data.onboardingTourProgressJson = Prisma.JsonNull;
}
}
if (parsed.data.progress !== undefined) {
data.onboardingTourProgressJson = parsed.data.progress;
}
await prisma.user.update({ where: { id: user.id }, data });
annotate({
action: { name: "onboarding.tour.update" },
meta: {
completed: parsed.data.completed,
completed: parsed.data.completed ?? null,
outcome: parsed.data.outcome ?? null,
progressStatus: parsed.data.progress?.status ?? null,
lastStopId: parsed.data.progress?.lastStopId ?? null,
},
});
return apiSuccess({ onboardingTourCompleted: parsed.data.completed });
return apiSuccess({
onboardingTourCompleted:
parsed.data.completed ?? user.onboardingTourCompleted,
progress: parsed.data.progress ?? null,
});
});
+17 -12
View File
@@ -6,6 +6,7 @@ import { Minimize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -61,6 +62,7 @@ export default function CoachPage() {
return (
<div
data-slot="coach-page"
data-tour-id="coach-hero"
// v1.18.1 (W-COACH-UI C1/C3) — the card was capped at
// `100dvh-13rem`, leaving a band of wasted space below it on a
// standalone route that no longer carries the Insights tab strip.
@@ -77,18 +79,21 @@ export default function CoachPage() {
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
leadingHeaderActions={
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleMinimize}
data-slot="coach-page-minimize"
aria-label={t("insights.coach.minimizeAriaLabel")}
title={t("insights.coach.minimizeAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Minimize2 className="size-4" aria-hidden="true" />
</Button>
<>
<ModuleTourTrigger stopId="coach" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleMinimize}
data-slot="coach-page-minimize"
aria-label={t("insights.coach.minimizeAriaLabel")}
title={t("insights.coach.minimizeAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Minimize2 className="size-4" aria-hidden="true" />
</Button>
</>
}
/>
</div>
+6 -1
View File
@@ -22,6 +22,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function LabsPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -63,7 +64,10 @@ export default function LabsPage() {
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
<h1
data-tour-id="labs-hero"
className="text-2xl font-bold tracking-tight"
>
{t("labs.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
@@ -71,6 +75,7 @@ export default function LabsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="labs" />
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
+15 -8
View File
@@ -16,6 +16,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -110,7 +111,10 @@ export default function MeasurementsPage() {
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
<h1
data-tour-id="measurements-hero"
className="text-2xl font-bold tracking-tight"
>
{t("measurements.title")}
</h1>
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
@@ -120,13 +124,16 @@ export default function MeasurementsPage() {
{t("measurements.subtitle")}
</p>
</div>
<Button
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setDialogOpen(true)}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="measurements" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => setDialogOpen(true)}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</div>
</div>
<ResponsiveSheet
+6 -1
View File
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
@@ -328,7 +329,10 @@ export default function MedicationsPage() {
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
<h1
data-tour-id="medications-hero"
className="text-2xl font-bold tracking-tight"
>
{t("medications.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
@@ -338,6 +342,7 @@ export default function MedicationsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="medications" />
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
permanent fixture: it renders only while at least TWO
medications are currently due (a single due dose is the
+6 -1
View File
@@ -15,6 +15,7 @@ import { Plus, Loader2, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -65,7 +66,10 @@ export default function MoodPage() {
<PullToRefreshIndicator {...pull} />
<div className="flex items-center justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
<h1
data-tour-id="mood-hero"
className="text-2xl font-bold tracking-tight"
>
{t("mood.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
@@ -75,6 +79,7 @@ export default function MoodPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="mood" />
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
+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";
@@ -691,15 +690,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
+15 -8
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
@@ -139,20 +140,26 @@ export function CycleView() {
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
<h1
data-tour-id="cycle-hero"
className="text-2xl font-bold tracking-tight"
>
{t("cycle.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("cycle.subtitle")}
</p>
</div>
<Button
onClick={() => openSheet(today)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="cycle" />
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</div>
</div>
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
@@ -23,6 +23,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
@@ -96,6 +97,8 @@ export function DashboardHeader({
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
<ModuleTourTrigger stopId="dashboardOverview" />
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
+15 -8
View File
@@ -32,6 +32,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { LogDaySheet } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
@@ -256,20 +257,26 @@ export function IllnessView() {
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
<h1
data-tour-id="illness-hero"
className="text-2xl font-bold tracking-tight"
>
{t("illness.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("illness.subtitle")}
</p>
</div>
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="illness" />
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
</div>
</div>
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
@@ -19,6 +19,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
@@ -656,6 +657,7 @@ function InsightsTabStripImpl({
return (
<nav
data-slot="insights-tab-strip"
data-tour-id="insights-hero"
aria-label={t("insights.navAriaLabel")}
// v1.4.28 FB-D3 — `touch-action: pan-y` lets vertical swipes that
// begin on the sticky strip scroll the page through it. The
@@ -858,6 +860,10 @@ function InsightsTabStripImpl({
"from-background/95 pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l to-transparent sm:hidden",
)}
/>
{/* v1.18.6 — per-module tour re-entry, travelling with the
top-right control cluster. Anchors to this strip's
`insights-hero` and opens the Insights tour card on the spot. */}
<ModuleTourTrigger stopId="insights" />
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
regenerate button and links to the Insights settings section
(overview arrange + pill sort), matching the Dashboard cog
+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
@@ -32,6 +32,7 @@ import { useState } from "react";
import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AlertDialog,
@@ -370,6 +371,7 @@ export function VorsorgeSection({
<div className="min-w-0">
<h1
id="vorsorge-section-title"
data-tour-id="vorsorge-hero"
className="text-2xl font-bold tracking-tight"
>
{t("measurementReminders.sectionTitle")}
@@ -379,6 +381,7 @@ export function VorsorgeSection({
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="vorsorge" />
{addButton}
{wrenchButton}
</div>
+14
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import {
CheckCircle2,
FileUp,
@@ -10,6 +11,8 @@ import {
} 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";
/**
@@ -24,10 +27,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");
});
});
@@ -0,0 +1,53 @@
"use client";
/**
* v1.18.6 — per-module "Diese Tour zeigen" re-entry.
*
* A small ghost button each module page drops into its header action
* cluster. It dispatches the `healthlog:module-tour` window event with
* the page's stop id; the shell-level `<TourLauncher>` picks it up and
* opens the spotlight narrowed to that single module card on the
* current page (no cross-page navigation, no completion flip).
*
* Anchors to the page's `data-tour="<…>-hero"` element via the launcher
* → overlay path. The button itself carries no anchor — it only fires
* the event.
*/
import { Compass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import type { TourStopId } from "@/lib/onboarding/tour-state";
export function ModuleTourTrigger({ stopId }: { stopId: TourStopId }) {
const { t } = useTranslations();
function show() {
if (typeof window === "undefined") return;
try {
window.dispatchEvent(
new CustomEvent("healthlog:module-tour", { detail: { stopId } }),
);
} catch {
/* ignore — sandboxed iframes etc. */
}
}
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={show}
// 44px mobile tap floor, matching the rest of the module headers.
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
data-testid={`module-tour-trigger-${stopId}`}
>
<Compass className="h-4 w-4" />
<span className="sr-only sm:not-sr-only">
{t("onboarding.tour.moduleTrigger")}
</span>
</Button>
);
}
+122 -24
View File
@@ -47,11 +47,13 @@
* manual replay always works.
*/
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { useCallback, 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,55 @@ 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);
};
}, []);
// v1.18.6 — stable progress callback so the overlay's checkpoint effect
// fires on a real step transition, not on every launcher re-render.
const handleProgress = useCallback(
(progress: { lastStopId: string | null; completedStopIds: string[]; status: "in_progress" | "completed" | "skipped" }) => {
void apiPost("/api/onboarding/tour", {
progress: { ...progress, updatedAt: new Date().toISOString() },
}).catch(() => {});
},
[],
);
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}
// 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.
onProgress={handleProgress}
onClose={async (outcome) => {
// Optimistic: hide the overlay immediately, persist in the
// background. The flag is idempotent so re-fires are safe;
@@ -378,7 +470,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
@@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
@@ -69,6 +69,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
@@ -119,12 +119,13 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.");
});
it("<AdvancedSection> exposes the Restart-tour button (moved from Account)", () => {
// v1.18.1 (D1) — the "Restart onboarding tour" card moved from Settings →
// Account into Settings → Advanced (a maintenance/reset action).
it("<AdvancedSection> exposes the single Restart-tour card", () => {
// v1.18.1 (D1) — the tour-replay card moved from Account into Advanced.
// v1.18.6 — relabelled "Restart module tour" and consolidated as the one
// full-tour replay home (the About duplicate was removed).
const html = render(<AdvancedSection />);
expect(html).toContain('data-testid="settings-restart-tour"');
expect(html).toContain("Restart onboarding tour");
expect(html).toContain("Restart module tour");
// No raw i18n key leaks.
expect(html).not.toContain("onboarding.tour.");
});
@@ -148,27 +149,13 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.about.");
});
it("<AboutSection> exposes a Replay-tour button for the W5 chain gate", () => {
it("<AboutSection> no longer carries a tour-replay card (consolidated to Advanced)", () => {
// v1.18.6 — the tour replay lives on one home in Settings → Advanced;
// the About duplicate was removed. Per-module "Diese Tour zeigen"
// affordances cover in-context re-entry.
const html = render(<AboutSection />);
// v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h
// after the wizard finishes (see `shouldAutoLaunchTour` in
// `components/onboarding/tour-launcher.tsx`). A first-day user who
// *does* want the tour needs a discoverable manual trigger, and
// Settings → About is the surface every user can find. Pin both
// the data-testid and the visible copy so a future refactor can't
// silently drop the button.
expect(html).toContain('data-testid="about-replay-tour"');
expect(html).toContain("Replay the tour");
// No raw i18n key leaks past the i18n layer.
expect(html).not.toContain("settings.about.tourReplay");
});
it("<AboutSection> renders the German Replay-tour label end-to-end", () => {
// project-voice + i18n parity: the W5 spec explicitly names this
// surface "Tour ansehen" in German. Lock the resolved copy so a
// future locale-bundle drift doesn't silently fall back to English.
const html = render(<AboutSection />, "de");
expect(html).toContain("Tour ansehen");
expect(html).not.toContain('data-testid="about-replay-tour"');
expect(html).not.toContain("Replay the tour");
});
it("<AiSection> renders the AI Insights heading", () => {
@@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
@@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference,
+5 -116
View File
@@ -5,7 +5,6 @@ import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
BookOpen,
Compass,
ExternalLink,
GitBranch,
Info,
@@ -15,9 +14,7 @@ import {
import { Button } from "@/components/ui/button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -118,25 +115,9 @@ export function UpdateBadge({
);
}
export interface AboutSectionProps {
/**
* v1.18.1 E5 — suppress the "Replay tour" card. The admin → Über
* surface reuses this component but the onboarding tour is a
* user-facing affordance with no place in the admin console, so the
* admin renderer passes `hideTourReplay`. The Settings → About surface
* leaves it on.
*/
hideTourReplay?: boolean;
}
export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) {
export function AboutSection() {
const { t } = useTranslations();
const fmt = useFormatters();
// Pull the auth user — only needed so the "Replay the tour" button
// can stamp a per-user force-launch marker into sessionStorage. The
// hook is cheap (a cached `/api/auth/me` read) and every settings
// page already lives behind the same auth boundary.
const { user } = useAuth();
const { data: version, isLoading } = useQuery({
queryKey: queryKeys.apiVersion(),
@@ -147,45 +128,6 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
staleTime: Infinity,
});
// v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto-
// launches only ≥ 24 h after the wizard finishes (see
// `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`).
// The carousel + tour no longer stack into ~90 s of forced onboarding
// on first visit. The tradeoff: a first-day user who *does* want the
// tour needs a discoverable manual trigger. Settings → About is the
// surface every user can find (every locale puts "About" right on the
// settings shell), so it carries the replay button next to the
// sources/docs links. Settings → Account still has its own
// "Restart onboarding tour" — both buttons share the same flow.
const [replayingTour, setReplayingTour] = useState(false);
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (a "success" message rendered in the
// destructive colour was the latent bug class).
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
async function handleReplayTour() {
setReplayingTour(true);
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. The helper returns a discriminated result; this surface
// only owns the translation + feedback rendering.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setReplayingTour(false);
}
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
// only the 24 h auto-check stays. When the auto-check reports
// `newer_available`, a subtle upward-arrow badge appears next to
@@ -364,63 +306,10 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
</div>
)}
{/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
tour now auto-launches only ≥ 24 h after the wizard finishes,
so a first-day user who wants to see it still needs a
discoverable manual trigger. About is the surface every user
can find (it's the standard "where am I?" stop in the
settings shell), so the button lives here next to version +
links. Mirrors the stack-on-mobile / right-align-on-desktop
contract used by the Account section's "Restart onboarding
tour" card so the two surfaces feel consistent.
v1.18.1 E5 — hidden on the admin → Über surface (`hideTourReplay`):
the onboarding tour is a user-facing affordance with no place in
the admin console. */}
{!hideTourReplay && (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.about.tourReplay")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={handleReplayTour}
disabled={replayingTour}
data-testid="about-replay-tour"
className="w-full shrink-0 sm:w-auto"
>
{replayingTour ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="h-4 w-4" />
)}
{t("settings.about.tourReplay")}
</Button>
</div>
{tourFeedback && (
<p
role="alert"
className={`mt-2 pl-7 text-xs ${
tourFeedback.type === "success"
? "text-success"
: "text-destructive"
}`}
>
{tourFeedback.text}
</p>
)}
</div>
)}
{/* v1.18.6 — the tour-replay card consolidated onto a single home in
Settings → Advanced ("Modul-Tour neu starten"). The About surface
no longer duplicates it; per-module "Diese Tour zeigen" affordances
on every module page cover in-context re-entry. */}
{/* v1.4.36 W4f — the dedicated "Updates" card with the
manual "Check for updates" button is gone. The 24 h auto-
@@ -19,6 +19,7 @@
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export function GesundheitsakteSection() {
const { t } = useTranslations();
@@ -28,13 +29,18 @@ export function GesundheitsakteSection() {
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header>
<header className="flex items-center justify-end">
<h1
id="settings-section-gesundheitsakte-title"
className="sr-only"
>
{t("settings.sections.gesundheitsakte.title")}
</h1>
{/* v1.18.6 — guided-tour re-entry for the doctor-report module + a
real spotlight anchor (the section h1 is sr-only). */}
<span data-tour-id="export-hero">
<ModuleTourTrigger stopId="export" />
</span>
</header>
<HealthRecordExportPanel />
@@ -17,6 +17,7 @@
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { useTranslations } from "@/lib/i18n/context";
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export {
parseOAuthOutcome,
@@ -32,10 +33,16 @@ export function IntegrationsSection() {
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
<header>
<header className="flex items-center justify-end">
<h1 id="settings-section-integrations-title" className="sr-only">
{t("settings.sections.integrations.title")}
</h1>
{/* v1.18.6 — guided-tour re-entry for the integrations module. The
visible affordance also gives the spotlight a real anchor (the
section h1 is sr-only). */}
<span data-tour-id="integrations-hero">
<ModuleTourTrigger stopId="integrations" />
</span>
</header>
<ConnectionsPanel />
+17
View File
@@ -10,8 +10,15 @@ import { useTranslations } from "@/lib/i18n/context";
import type { TimeFormatPreference } from "@/lib/format-locale";
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
import type { ModuleKey } from "@/lib/modules/registry";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
/**
* v1.18.6 — resume point for the module tour as the client sees it on
* `/api/auth/me`. Mirrors the server-side `TourProgress` shape.
*/
export type AuthTourProgress = TourProgress;
export interface AuthUser {
id: string;
username: string;
@@ -29,6 +36,13 @@ export interface AuthUser {
* `<TourLauncher>` component for the gating logic.
*/
onboardingTourCompleted: boolean;
/**
* v1.18.6 — resumable module-tour progress, or null when the user
* has not started the tour. The `<TourLauncher>` seeds its resume
* index from `lastStopId`. Distinct from the coarse
* `onboardingTourCompleted` boolean, which stays the auto-launch gate.
*/
onboardingTourProgress: AuthTourProgress | null;
/**
* v1.5.5 — relative URL of the user's self-hosted avatar, served
* from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the
@@ -137,6 +151,9 @@ async function fetchMe(): Promise<AuthUser> {
return {
...(data as AuthUser),
disableCoach: data.disableCoach ?? false,
// v1.18.6 — coerce against a stale /me payload (older server image
// without the field) to null so the tour starts from the top.
onboardingTourProgress: data.onboardingTourProgress ?? null,
// v1.7.0 — coerce against a stale /me payload (older server image
// without the field) so the display defaults to metric.
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
@@ -91,6 +91,7 @@ function buildUser(disableCoach: boolean): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import {
parseTourProgress,
tourProgressSchema,
type TourProgress,
} from "@/lib/onboarding/tour-progress";
const valid: TourProgress = {
lastStopId: "labs",
completedStopIds: ["dashboardOverview", "quickAdd"],
status: "in_progress",
updatedAt: "2026-06-18T10:00:00.000Z",
};
describe("tour-progress", () => {
it("parses a well-formed progress object", () => {
expect(parseTourProgress(valid)).toEqual(valid);
});
it("treats null / undefined as not-started", () => {
expect(parseTourProgress(null)).toBeNull();
expect(parseTourProgress(undefined)).toBeNull();
});
it("degrades a corrupt blob to null rather than throwing", () => {
expect(parseTourProgress({ status: "bogus" })).toBeNull();
expect(parseTourProgress({ lastStopId: 42 })).toBeNull();
expect(parseTourProgress("not-an-object")).toBeNull();
});
it("defaults completedStopIds to an empty array", () => {
const parsed = tourProgressSchema.parse({
lastStopId: null,
status: "skipped",
updatedAt: "2026-06-18T10:00:00.000Z",
});
expect(parsed.completedStopIds).toEqual([]);
});
it("accepts a null lastStopId (centred/wrap stop or fresh start)", () => {
expect(parseTourProgress({ ...valid, lastStopId: null })).not.toBeNull();
});
it("rejects an over-long completedStopIds array", () => {
const tooMany = Array.from({ length: 65 }, (_, i) => `s${i}`);
expect(
parseTourProgress({ ...valid, completedStopIds: tooMany }),
).toBeNull();
});
});
+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");
});
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* v1.18.6 — resumable module-tour progress.
*
* Pure data + Zod schema for the `users.onboarding_tour_progress_json`
* column. Kept DOM-free + free of Prisma imports so vitest (node env)
* and the OpenAPI registry can both consume it.
*
* The coarse `onboardingTourCompleted` boolean stays the launcher's
* auto-launch gate; this object is the fine-grained resume point so a
* mid-tour reload reopens at the right module. `null` = the user has
* not started the module tour.
*/
import { z } from "zod/v4";
/** Terminal / running status of the module tour. */
export const TOUR_PROGRESS_STATUSES = [
"in_progress",
"completed",
"skipped",
] as const;
export type TourProgressStatus = (typeof TOUR_PROGRESS_STATUSES)[number];
/**
* The persisted progress shape. `lastStopId` seeds the resume index;
* `completedStopIds` is informational (analytics + future per-module
* "already seen" hints). Stop ids are bounded strings — the client
* sends a known stop id from `buildTourStops()`, but the server does
* not couple to that closed set (a future stop must not 422 an older
* client and vice-versa), so the field is a length-capped string array
* rather than a hard enum.
*/
export const tourProgressSchema = z.object({
lastStopId: z.string().min(1).max(64).nullable(),
completedStopIds: z.array(z.string().min(1).max(64)).max(64).default([]),
status: z.enum(TOUR_PROGRESS_STATUSES),
updatedAt: z.iso.datetime(),
});
export type TourProgress = z.infer<typeof tourProgressSchema>;
/**
* Parse a value read from the JSON column. Returns `null` for a null /
* absent column AND for any malformed payload — the resume point is a
* non-critical convenience, so a corrupt blob degrades to "start from
* the top" rather than throwing into the auth/me read path.
*/
export function parseTourProgress(value: unknown): TourProgress | null {
if (value == null) return null;
const parsed = tourProgressSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
+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/gesundheitsakte",
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,
};
}
+2
View File
@@ -42,6 +42,7 @@ import { measurementReminderPaths } from "./measurement-reminders";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
import { onboardingPaths } from "./onboarding";
import { profilePaths } from "./profile";
import { settingsPaths } from "./settings";
import { syncPaths } from "./sync";
@@ -72,6 +73,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...labsPaths,
...biomarkerPaths,
...illnessPaths,
...onboardingPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+81
View File
@@ -0,0 +1,81 @@
/**
* OpenAPI route table — onboarding module tour.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* The request schema comes from `src/lib/onboarding/tour-progress.ts`
* so the wire contract stays single-source with the runtime parser.
*
* v1.18.6 — the resumable module-tour contract the iOS client mirrors:
* a fire-and-forget progress checkpoint plus the coarse completion
* flip. The resume point also rides `GET /api/auth/me` as
* `onboardingTourProgress`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
import { dataEnvelope, stdResponses } from "./shared";
const tourProgressResource = tourProgressSchema.meta({
id: "TourProgress",
description:
"Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state.",
});
const tourUpdateRequest = z
.object({
completed: z
.boolean()
.optional()
.describe(
"Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point.",
),
outcome: z
.enum(["completed", "skipped"])
.optional()
.describe("Informational — distinguishes reaching the end from a skip."),
progress: tourProgressResource
.optional()
.describe("Mid-tour resume checkpoint. May arrive alone or with `completed`."),
})
.meta({
id: "TourUpdateRequest",
description:
"Update the module-tour state. Provide `completed` and/or `progress`.",
});
const tourUpdateResponse = z
.object({
onboardingTourCompleted: z.boolean(),
progress: tourProgressResource.nullable(),
})
.meta({
id: "TourUpdateResponse",
description: "The persisted completion flag and resume point after the write.",
});
export const onboardingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/onboarding/tour": {
post: {
tags: ["Onboarding"],
summary: "Update module-tour completion + resume point",
description:
"Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends. `completed:false` is a replay reset that also clears the resume point.",
requestBody: {
required: true,
content: { "application/json": { schema: tourUpdateRequest } },
},
responses: {
"200": {
description: "Tour state updated.",
content: {
"application/json": {
schema: dataEnvelope(tourUpdateResponse, "TourUpdateEnvelope"),
},
},
},
...stdResponses,
},
},
},
};