diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6868782b2..b55ba0e06 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 21ee4c4f2..d9fab76e8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index e91951fe9..eed712aac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 6f2928108..2bafb2f5c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index d2a4076fe..591813a41 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 8873b3ec7..2a850296e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 0f4eb78f3..345078605 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0173_v1186_tour_progress/migration.sql b/prisma/migrations/0173_v1186_tour_progress/migration.sql new file mode 100644 index 000000000..498a859f5 --- /dev/null +++ b/prisma/migrations/0173_v1186_tour_progress/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a69270678..948d0db58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 513927772..f0a9448c3 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -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 (
-
-

- {t("achievements.title")} -

-

- {t("achievements.subtitle")} -

+
+
+

+ {t("achievements.title")} +

+

+ {t("achievements.subtitle")} +

+
+
+ +
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 8bd9e34be..aeea25435 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -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 ; + // v1.18.6 — the About surface no longer carries the tour-replay card + // (consolidated onto Settings → Advanced), so there's nothing to hide. + return ; default: slug satisfies never; return null; diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index f7fec1a52..d23f63b8e 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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 diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 9e9b72a4a..8d88d408a 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -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, + }); }); diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 23363d12e..d8c0d5402 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -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 (
{title} )} leadingHeaderActions={ - + <> + + + } />
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 8ef9835e3..98196c102 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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() {
-

+

{t("labs.title")}

@@ -71,6 +75,7 @@ export default function LabsPage() {

+ +
+ + +
-

+

{t("medications.title")}

{/* v1.4.34 IW-G — subtitle stays visible on mobile so the @@ -338,6 +342,7 @@ export default function MedicationsPage() {

+ {/* 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 diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 8d959076e..9a8485974 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -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() {
-

+

{t("mood.title")}

{/* v1.4.34 IW-G — subtitle stays visible on mobile so the @@ -75,6 +79,7 @@ export default function MoodPage() {

+ {/* 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 diff --git a/src/app/page.tsx b/src/app/page.tsx index 8de115c44..4f267de25 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -52,7 +52,6 @@ import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; import { TrendHint } from "@/components/charts/trend-hint"; import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; -import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; @@ -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. */} - + {/* 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 `
-

+

{t("cycle.title")}

{t("cycle.subtitle")}

- +
+ + +
{/* Desktop: ring (left) + tabs (right). Single column below lg. */} diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 79aa15331..54ce87021 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -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}
+ {/* v1.18.6 — re-enter the guided module tour from the dashboard. */} + {/* 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 diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 88d533285..49ffe5b9c 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -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() {
-

+

{t("illness.title")}

{t("illness.subtitle")}

- +
+ + +

{t("illness.disclaimer")}

diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 3897468e7..67c74ab16 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -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 (
+ {/* v1.18.6 — the module-tour launcher lives at the shell level so its + overlay survives the cross-page `router.push`es the tour makes. It + self-gates: it only auto-opens on the dashboard for a user who has + not finished/dismissed the tour, and otherwise renders null. Stays + out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the + proxy blocks for demo visitors). */} + {!demoMode && } {/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits `/api/insights/chat`, which is not in the proxy's `DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index eb814c05b..3ec86c63a 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -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({

{t("measurementReminders.sectionTitle")} @@ -379,6 +381,7 @@ export function VorsorgeSection({

+ {addButton} {wrenchButton}
diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/DoneScreen.tsx index 3c6d7e01e..5e02f3716 100644 --- a/src/components/onboarding/DoneScreen.tsx +++ b/src/components/onboarding/DoneScreen.tsx @@ -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 `` auto-opens the module tour immediately + * on the next dashboard arrival (sequenced after the post-wizard + * grace), instead of waiting out the 24 h fallback. Whichever exit the + * user picks from this screen, the next `/` mount finds the marker. */ export function DoneScreen() { const { t } = useTranslations(); + const { user } = useAuth(); + + useEffect(() => { + if (user?.id) setTourReferrer(user.id); + }, [user?.id]); return (
({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/", +})); import { I18nProvider } from "@/lib/i18n/context"; import { OnboardingTour } from "../tour"; -function renderTour(includeAchievements = true) { +function renderTour(modules?: Record) { return renderToStaticMarkup( - + , ); } @@ -31,25 +35,23 @@ describe("", () => { expect(html).toContain('aria-labelledby="onboarding-tour-title"'); }); - it("starts on the tile-strip step (1 of 5 by default)", () => { + it("starts on the dashboard-overview step (1 of 15 by default)", () => { const html = renderTour(); - expect(html).toContain("Step 1 of 5"); - expect(html).toContain("Your daily numbers"); - // Body of the first step should mention the tile strip context. - expect(html).toContain("tile strip"); + expect(html).toContain("Step 1 of 15"); }); - it("drops the achievements stop when the flag is off (1 of 4)", () => { - const html = renderTour(false); - expect(html).toContain("Step 1 of 4"); + it("keeps the counter honest when modules are disabled", () => { + // cycle + mood + achievements off ⇒ 12 stops. + const html = renderTour({ + cycle: false, + mood: false, + achievements: false, + }); + expect(html).toContain("Step 1 of 12"); }); it("exposes a skip control with an accessible label", () => { const html = renderTour(); - // After v1.4.33 F2 the dim panels are purely visual - // (pointer-events: none) so skip lives entirely on the explicit - // tooltip-footer Skip button. The visible label still has to be - // there — both for sighted users and for screen readers. expect(html).toContain("Skip tour"); }); @@ -77,8 +79,7 @@ describe("", () => { , ); - expect(html).toContain("Schritt 1 von 5"); - expect(html).toContain("Deine Tageswerte"); + expect(html).toContain("Schritt 1 von 15"); expect(html).toContain("Tour überspringen"); }); }); diff --git a/src/components/onboarding/module-tour-trigger.tsx b/src/components/onboarding/module-tour-trigger.tsx new file mode 100644 index 000000000..a502cbf16 --- /dev/null +++ b/src/components/onboarding/module-tour-trigger.tsx @@ -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 `` 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 ( + + ); +} diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 3095542df..c76b6c0d1 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -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(null); + // v1.18.6 — when the tour opens for a single-module re-entry + // ("Diese Tour zeigen" on a module page) this carries the stop id so + // the overlay renders just that card. `null` = the full tour. + const [singleStop, setSingleStop] = useState(null); // Track post-wizard deferral as a boolean so the render path stays // pure (no Date.now() comparisons). The effect below reads / clears it. const [deferredFromWizard, setDeferredFromWizard] = useState(false); @@ -275,7 +312,14 @@ export function TourLauncher({ ready }: TourLauncherProps) { // tour on their next page-load — acceptable for a one-shot welcome). const [mountedAtMs] = useState(() => Date.now()); - const inputsReady = !isLoading && isAuthenticated && !!user && ready; + // v1.18.6 — the launcher now mounts at the app-shell level so the + // overlay survives the cross-page `router.push`es the tour makes. It + // must only AUTO-open on the dashboard, where the first stop's anchor + // lives. A manual re-entry / restart can fire from any route (handled + // by the event listeners below, which don't consult this gate). + const onDashboard = pathname === "/"; + const inputsReady = + !isLoading && isAuthenticated && !!user && ready && onDashboard; // The set-state-in-render pattern (mirrors `account-section.tsx`'s // `seededUserId` — React-recommended for "compute initial state // from props once they arrive"). Pure: branches on prop values @@ -289,6 +333,10 @@ export function TourLauncher({ ready }: TourLauncherProps) { decidedFor.flag !== user.onboardingTourCompleted) ) { setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted }); + // v1.18.6 — consume the wizard-return marker once so the + // fresh-from-anamnesis path launches immediately (sequenced after + // the post-wizard grace) instead of waiting 24 h. + const justFromWizard = readAndClearJustFromWizard(user.id); // sessionStorage reads happen via per-user keyed helpers, so a // browser shared by two HealthLog users (admin impersonation, // family laptop, etc.) keeps each user's tour state independent. @@ -309,17 +357,21 @@ export function TourLauncher({ ready }: TourLauncherProps) { // user expectation ("I clicked replay, now show me the tour"). if (readAndClearForceLaunch(user.id)) { setShowTour(true); - } else if (!shouldAutoLaunchTour({ - onboardingTourCompleted: user.onboardingTourCompleted, - onboardingCompletedAt: user.onboardingCompletedAt, - nowMs: mountedAtMs, - })) { + } else if ( + !shouldAutoLaunchTour({ + onboardingTourCompleted: user.onboardingTourCompleted, + onboardingCompletedAt: user.onboardingCompletedAt, + nowMs: mountedAtMs, + justFromWizard, + }) + ) { setShowTour(false); } else if (readSessionDismissed(user.id)) { setShowTour(false); - } else if (readAndClearJustFromWizard(user.id)) { - // Mark "deferred"; the effect below schedules a fixed-duration - // timer + flips `showTour` from inside the timeout callback + } else if (justFromWizard) { + // Fresh from anamnesis — defer past the post-wizard grace so the + // green-tick toast doesn't double-overlay, then open. The effect + // below schedules the timer and flips `showTour` in its callback // (which the set-state-in-effect rule allows). setShowTour(false); setDeferredFromWizard(true); @@ -344,13 +396,15 @@ export function TourLauncher({ ready }: TourLauncherProps) { return () => window.clearTimeout(id); }, [deferredFromWizard, user?.id]); - // Listen for explicit restart events from Settings → Account. + // Listen for explicit restart events from Settings → Advanced. A + // restart opens the FULL tour from the top (singleStop cleared). // setState inside a window-event listener is fine under the // set-state-in-effect rule. const userIdForRestart = user?.id; useEffect(() => { function onRestart() { if (userIdForRestart) clearSessionDismissed(userIdForRestart); + setSingleStop(null); setShowTour(true); } window.addEventListener("healthlog:tour-restart", onRestart); @@ -359,17 +413,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?. !== 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 ( { // 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 }); diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index ae350d47e..852828568 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -36,6 +36,7 @@ * `skipped` to the API + audit log). */ +import { usePathname, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -43,26 +44,46 @@ import { useTranslations } from "@/lib/i18n/context"; import { buildTourStops, currentStop, + deriveProgress, initTourState, isTourFinished, nextStep, prevStep, skipTour, stepCounter, + type TourModuleMap, + type TourState, type TourStop, + type TourStopId, } from "@/lib/onboarding/tour-state"; interface TourProps { /** - * Whether the achievements page exists and should be a tour stop. - * The launcher passes `false` if B4 hasn't shipped to skip the - * stop without renumbering. + * Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose + * `requiresModule` resolves to `false` are dropped; the counter total + * tracks the resolved list so "Schritt n/total" stays honest. */ - includeAchievements?: boolean; + modules?: TourModuleMap; /** - * Called when the tour finishes (Done OR Skip OR Esc OR Backdrop - * click). Receives the terminal outcome so the launcher can audit - * the difference. + * v1.18.6 — open at this stop id (the persisted resume point). When + * the id is absent from the resolved list the tour starts at the top. + */ + resumeFromStopId?: string | null; + /** + * v1.18.6 — narrow the tour to a single module card (the per-page + * "Diese Tour zeigen" re-entry). When set, the overlay shows just + * that stop with a Done button and no cross-page navigation. + */ + filterToStop?: TourStopId; + /** + * v1.18.6 — fire-and-forget progress checkpoint on every step change + * + terminal. The launcher PATCHes it so a reload resumes correctly. + * Not called for the single-stop re-entry (`filterToStop`). + */ + onProgress?: (progress: ReturnType) => void; + /** + * Called when the tour finishes (Done OR Skip OR Esc). Receives the + * terminal outcome so the launcher can audit the difference. */ onClose: (outcome: "completed" | "skipped") => void; } @@ -216,56 +237,125 @@ const COLLISION_PAD = 8; const SHEET_BREAKPOINT = 480; export function OnboardingTour({ - includeAchievements = true, + modules, + resumeFromStopId, + filterToStop, + onProgress, onClose, }: TourProps) { const { t } = useTranslations(); + const router = useRouter(); + const pathname = usePathname(); const stops = useMemo( - () => buildTourStops({ includeAchievements }), - [includeAchievements], + () => buildTourStops({ modules, filterToStop }), + [modules, filterToStop], + ); + // Single-stop re-entry never resumes from a checkpoint — it opens on + // the one card the page asked for. + const [state, setState] = useState(() => + initTourState(stops, filterToStop ? null : resumeFromStopId), ); - const [state, setState] = useState(() => initTourState(stops)); const [rect, setRect] = useState(null); const stop = currentStop(state); - // Re-measure on step change, viewport resize, and scroll. We intentionally - // *don't* MutationObserver the whole document — the targets are stable in - // the layout and re-measuring on rAF after scroll/resize is plenty. + // v1.18.6 — cross-page step change. A stop on another route triggers a + // `router.push` first; the overlay survives the navigation because it is + // mounted at the app-shell level (not inside the dashboard). After the + // route settles we must wait for the new page's anchor to MOUNT before + // measuring — anchoring against a not-yet-rendered (0×0) target would snap + // the spotlight to centre-screen. We poll on rAF with a bounded retry, then + // give up to the centred fallback so the tour never blocks indefinitely. + // + // We intentionally *don't* MutationObserver the whole document — the + // targets are stable once mounted; bounded rAF polling on step change plus + // re-measuring on scroll/resize is plenty. useEffect(() => { if (!stop) return; + if (typeof document === "undefined") return; + let raf = 0; + let cancelled = false; + const reduceMotion = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; + + // Navigate to the stop's route if we're not already there. Same-route + // stops (the two dashboard stops while on `/`, or the wrap-up which has + // no route) skip the push. `filterToStop` re-entry never navigates — it + // runs on the page the user is already viewing. + if (!filterToStop && stop.route && stop.route !== pathname) { + router.push(stop.route); + } + + const measureNow = () => { + if (cancelled) return; + setRect(measureTarget(stop.targetId)); + }; const measure = () => { cancelAnimationFrame(raf); - raf = requestAnimationFrame(() => { - setRect(measureTarget(stop.targetId)); - }); + raf = requestAnimationFrame(measureNow); }; - // Bring the target into view BEFORE the first measurement of this - // step — anchors low on the page (e.g. the Settings nav item) can - // sit entirely below the fold, and positioning against an - // off-screen rect put the popover under the viewport bottom where - // its Next button was unreachable. `block: "center"` keeps room on - // both sides for the flipped placement; the rAF in `measure()` - // runs after the instant scroll so we measure post-scroll - // coordinates. Optional-chained: jsdom and older engines lack it. - if (stop.targetId && typeof document !== "undefined") { - document - .querySelector( - `[data-tour-id="${CSS.escape(stop.targetId)}"]`, - ) - ?.scrollIntoView?.({ block: "center", inline: "nearest" }); - } - measure(); + + // Wait for the anchor to mount (after navigation it isn't in the DOM on + // the first frame). Bounded poll: ~40 frames (~650 ms) before falling + // back to the centred tooltip via `measureTarget` returning null. + let attempts = 0; + const MAX_ATTEMPTS = 40; + const settleAndMeasure = () => { + if (cancelled) return; + const el = stop.targetId + ? document.querySelector( + `[data-tour-id="${CSS.escape(stop.targetId)}"]`, + ) + : null; + if (el) { + // Bring the anchor into view BEFORE measuring — anchors below the + // fold positioned the popover under the viewport bottom otherwise. + // Reduced-motion users get an instant jump, not a smooth scroll. + el.scrollIntoView?.({ + block: "center", + inline: "nearest", + behavior: reduceMotion ? "auto" : "smooth", + }); + measure(); + return; + } + // Centred / route-less stop (wrap-up): measure immediately. + if (!stop.targetId) { + measureNow(); + return; + } + attempts += 1; + if (attempts < MAX_ATTEMPTS) { + raf = requestAnimationFrame(settleAndMeasure); + } else { + // Anchor never mounted — centre the tooltip rather than block. + measureNow(); + } + }; + settleAndMeasure(); + window.addEventListener("resize", measure); window.addEventListener("scroll", measure, true); return () => { + cancelled = true; cancelAnimationFrame(raf); window.removeEventListener("resize", measure); window.removeEventListener("scroll", measure, true); }; - }, [stop]); + // `pathname` is intentionally a dep: after the route settles the effect + // re-runs and the anchor poll finds the now-mounted target. + }, [stop, filterToStop, router, pathname]); + + // v1.18.6 — fire-and-forget progress checkpoint on every step / terminal + // transition so a mid-tour reload resumes at the right module. Skipped for + // the single-stop re-entry (it isn't "the tour", just one card). + useEffect(() => { + if (filterToStop || !onProgress) return; + onProgress(deriveProgress(state)); + }, [state, filterToStop, onProgress]); // Focus management — moves focus to the primary action when the // step changes so keyboard users can hit Enter to advance. The diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 495fa9a59..aaf77d38b 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index af1183e3b..32d23c180 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -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", diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 1eddc6cca..50704d6e9 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -119,12 +119,13 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.sections."); }); - it(" 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(" 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(); 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(" exposes a Replay-tour button for the W5 chain gate", () => { + it(" 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(); - // 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(" 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(, "de"); - expect(html).toContain("Tour ansehen"); + expect(html).not.toContain('data-testid="about-replay-tour"'); + expect(html).not.toContain("Replay the tour"); }); it(" renders the AI Insights heading", () => { diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 848d95017..2bb55ac6b 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 13430adf1..c5e0a3b13 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference, diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index e79e452f8..847c43357 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -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 = {})
)} - {/* 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 && ( -
-
-
- -
-

- {t("settings.about.tourReplay")} -

-

- {t("settings.about.tourReplayHint")} -

-
-
- -
- {tourFeedback && ( -

- {tourFeedback.text} -

- )} -
- )} + {/* 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- diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index dfa9d047d..7bf511e54 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -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. */} -
+

{t("settings.sections.gesundheitsakte.title")}

+ {/* v1.18.6 — guided-tour re-entry for the doctor-report module + a + real spotlight anchor (the section h1 is sr-only). */} + + +
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 6ed4792c8..2bd9fc16c 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -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. */} -
+

{t("settings.sections.integrations.title")}

+ {/* 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). */} + + +
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index ba079e0c7..4fcf04a7d 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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 { * `` 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 `` 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 { 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", diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 594a040ad..9120db140 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -91,6 +91,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/lib/onboarding/__tests__/tour-progress.test.ts b/src/lib/onboarding/__tests__/tour-progress.test.ts new file mode 100644 index 000000000..52a108e8d --- /dev/null +++ b/src/lib/onboarding/__tests__/tour-progress.test.ts @@ -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(); + }); +}); diff --git a/src/lib/onboarding/__tests__/tour-state.test.ts b/src/lib/onboarding/__tests__/tour-state.test.ts index 450d73ce2..b46b1c8d6 100644 --- a/src/lib/onboarding/__tests__/tour-state.test.ts +++ b/src/lib/onboarding/__tests__/tour-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildTourStops, currentStop, + deriveProgress, initTourState, isTourFinished, nextStep, @@ -11,48 +12,82 @@ import { stepCounter, } from "../tour-state"; +const FULL_ORDER = [ + "dashboardOverview", + "quickAdd", + "measurements", + "medications", + "labs", + "illness", + "vorsorge", + "cycle", + "mood", + "insights", + "coach", + "integrations", + "export", + "achievements", + "wrapUp", +]; + describe("tour-state", () => { describe("buildTourStops()", () => { - it("includes the achievements stop by default (5 stops)", () => { + it("returns all 15 module stops in order with every module on", () => { const stops = buildTourStops(); - expect(stops.map((s) => s.id)).toEqual([ - "tile-strip", - "quick-add", - "insights", - "integrations", - "achievements", - ]); + expect(stops.map((s) => s.id)).toEqual(FULL_ORDER); }); - it("omits the achievements stop when the flag is off (4 stops)", () => { - const stops = buildTourStops({ includeAchievements: false }); - expect(stops.map((s) => s.id)).toEqual([ - "tile-strip", - "quick-add", - "insights", - "integrations", - ]); + it("drops a stop whose module resolves to false (default-on otherwise)", () => { + const stops = buildTourStops({ + modules: { cycle: false, mood: false, achievements: false }, + }); + const ids = stops.map((s) => s.id); + expect(ids).not.toContain("cycle"); + expect(ids).not.toContain("mood"); + expect(ids).not.toContain("achievements"); + // Core + un-disabled modules survive. + expect(ids).toContain("dashboardOverview"); + expect(ids).toContain("labs"); + expect(ids).toContain("wrapUp"); }); - it("every stop carries a non-empty i18n title and body key", () => { - const stops = buildTourStops(); - for (const s of stops) { + it("keeps a module stop when its key is absent or true (fail-open)", () => { + const stops = buildTourStops({ modules: { labs: true } }); + expect(stops.map((s) => s.id)).toContain("labs"); + expect(stops.map((s) => s.id)).toContain("mood"); + }); + + it("filterToStop narrows to a single module card", () => { + const stops = buildTourStops({ filterToStop: "labs" }); + expect(stops.map((s) => s.id)).toEqual(["labs"]); + }); + + it("filterToStop yields nothing when the module is disabled", () => { + const stops = buildTourStops({ + filterToStop: "cycle", + modules: { cycle: false }, + }); + expect(stops).toEqual([]); + }); + + it("every stop carries a distinct, namespaced i18n title and body key", () => { + for (const s of buildTourStops()) { expect(s.titleKey).toMatch(/^onboarding\.tour\.steps\./); expect(s.bodyKey).toMatch(/^onboarding\.tour\.steps\./); expect(s.titleKey).not.toBe(s.bodyKey); } }); - it("every stop declares a stable targetId for the data-tour-id contract", () => { - const stops = buildTourStops(); - for (const s of stops) { - // `null` is allowed (centred-screen tooltip without a - // spotlight cutout) but we currently use real targets for - // every stop — a regression here would mean the spotlight - // silently falls back to centre-screen. - expect(typeof s.targetId === "string" && s.targetId.length > 0).toBe( - true, - ); + it("every cross-page stop declares a route; the wrap-up is centred + routeless", () => { + for (const s of buildTourStops()) { + if (s.id === "wrapUp") { + expect(s.targetId).toBeNull(); + expect(s.route).toBeUndefined(); + expect(s.placement).toBe("center"); + } else { + expect(typeof s.route).toBe("string"); + expect(s.targetId).toBeTruthy(); + } } }); }); @@ -62,76 +97,97 @@ describe("tour-state", () => { const state = initTourState(buildTourStops()); expect(state.index).toBe(0); expect(state.outcome).toBeNull(); - expect(isTourFinished(state)).toBe(false); - expect(currentStop(state)?.id).toBe("tile-strip"); + expect(currentStop(state)?.id).toBe("dashboardOverview"); + }); + + it("resumes from a persisted stop id", () => { + const state = initTourState(buildTourStops(), "labs"); + expect(currentStop(state)?.id).toBe("labs"); + }); + + it("ignores a resume id absent from the resolved list", () => { + const state = initTourState( + buildTourStops({ modules: { cycle: false } }), + "cycle", + ); + expect(state.index).toBe(0); }); it("nextStep advances through the list", () => { let state = initTourState(buildTourStops()); state = nextStep(state); - expect(currentStop(state)?.id).toBe("quick-add"); - state = nextStep(state); - expect(currentStop(state)?.id).toBe("insights"); + expect(currentStop(state)?.id).toBe("quickAdd"); }); - it("nextStep on the last step finishes the tour with outcome=completed", () => { + it("nextStep on the last step finishes with outcome=completed", () => { const stops = buildTourStops(); let state = initTourState(stops); - // step through every stop - for (let i = 0; i < stops.length; i++) { - state = nextStep(state); - } + for (let i = 0; i < stops.length; i++) state = nextStep(state); expect(state.outcome).toBe("completed"); expect(isTourFinished(state)).toBe(true); expect(currentStop(state)).toBeNull(); }); - it("prevStep is pinned at index 0 (LeftArrow on first step is a no-op)", () => { + it("prevStep is pinned at index 0", () => { const state = initTourState(buildTourStops()); - const back = prevStep(state); - expect(back.index).toBe(0); - expect(back.outcome).toBeNull(); - }); - - it("prevStep walks backward when not at the start", () => { - let state = initTourState(buildTourStops()); - state = nextStep(state); - state = nextStep(state); - expect(state.index).toBe(2); - const back = prevStep(state); - expect(back.index).toBe(1); + expect(prevStep(state).index).toBe(0); }); it("skipTour from any step finishes with outcome=skipped", () => { let state = initTourState(buildTourStops()); - state = nextStep(state); // on step 2 of 5 + state = nextStep(state); const skipped = skipTour(state); expect(skipped.outcome).toBe("skipped"); - expect(isTourFinished(skipped)).toBe(true); expect(currentStop(skipped)).toBeNull(); }); - - it("nextStep / prevStep / skipTour are no-ops once finished", () => { - let state = initTourState(buildTourStops()); - state = skipTour(state); - const after = nextStep(prevStep(skipTour(state))); - expect(after).toEqual(state); - }); }); describe("stepCounter", () => { - it("reports 1-based current and the total step count", () => { + it("reports 1-based current and the resolved total", () => { const stops = buildTourStops(); let state = initTourState(stops); expect(stepCounter(state)).toEqual({ current: 1, total: stops.length }); state = nextStep(state); - expect(stepCounter(state)).toEqual({ current: 2, total: stops.length }); + expect(stepCounter(state).current).toBe(2); }); - it("clamps `current` to total when the tour is finished", () => { - let state = initTourState(buildTourStops({ includeAchievements: false })); + it("total tracks the gated list so the counter stays honest", () => { + const stops = buildTourStops({ modules: { cycle: false, mood: false } }); + const state = initTourState(stops); + expect(stepCounter(state).total).toBe(stops.length); + expect(stops.length).toBe(13); + }); + }); + + describe("deriveProgress", () => { + it("reports the current stop as the resume point while running", () => { + let state = initTourState(buildTourStops()); + state = nextStep(state); + state = nextStep(state); + const p = deriveProgress(state); + expect(p.lastStopId).toBe("measurements"); + expect(p.status).toBe("in_progress"); + expect(p.completedStopIds).toEqual([ + "dashboardOverview", + "quickAdd", + "measurements", + ]); + }); + + it("marks completed with every stop reached and a null resume point", () => { + const stops = buildTourStops(); + let state = initTourState(stops); + for (let i = 0; i < stops.length; i++) state = nextStep(state); + const p = deriveProgress(state); + expect(p.status).toBe("completed"); + expect(p.lastStopId).toBeNull(); + expect(p.completedStopIds).toHaveLength(stops.length); + }); + + it("marks skipped", () => { + let state = initTourState(buildTourStops()); state = skipTour(state); - expect(stepCounter(state)).toEqual({ current: 4, total: 4 }); + expect(deriveProgress(state).status).toBe("skipped"); }); }); }); diff --git a/src/lib/onboarding/tour-progress.ts b/src/lib/onboarding/tour-progress.ts new file mode 100644 index 000000000..b186ce7d3 --- /dev/null +++ b/src/lib/onboarding/tour-progress.ts @@ -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; + +/** + * 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; +} diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts index a8776cfc5..6106f2b67 100644 --- a/src/lib/onboarding/tour-state.ts +++ b/src/lib/onboarding/tour-state.ts @@ -1,114 +1,245 @@ /** * v1.4.15 Phase B5 — onboarding tour state model. + * v1.18.6 — rebuilt as a 15-stop module-by-module guided tour. * - * Pure data + state-machine helpers for the spotlight tour overlaid on - * the dashboard for first-time users. Kept DOM-free so vitest (Node - * environment, no jsdom) can exercise the navigation logic directly, - * matching the project test-style elsewhere. + * Pure data + state-machine helpers for the spotlight tour. Kept + * DOM-free so vitest (Node environment, no jsdom) can exercise the + * navigation logic directly. * - * The UI layer (`src/components/onboarding/tour.tsx`) builds the - * step list once on mount and then drives navigation through these - * helpers. + * The tour walks every module in the order data → intelligence → + * export, teaching each module's value, one hidden capability, and how + * it feeds the others. The UI layer (`src/components/onboarding/tour.tsx`) + * builds the step list once on mount and then drives navigation through + * these helpers. * - * Tour stops are anchored to UI targets via `data-tour-id` attributes - * (NOT class-name selectors — class names change too often during - * Tailwind tweaks; `data-tour-id` is a stable contract). Every stop - * declares its `targetId`; the spotlight component looks up the live - * element via `document.querySelector('[data-tour-id=""]')`. - * - * If a target element is missing at runtime (e.g. user is on a - * resized layout that hides it), the spotlight falls back to a - * centred-screen tooltip so the tour never blocks indefinitely. + * Stops anchor to UI targets via `data-tour-id` attributes (NOT + * class-name selectors — class names churn during Tailwind tweaks; + * `data-tour-id` is a stable contract). A stop on another route also + * carries a `route`; the overlay `router.push`es there, waits for the + * anchor to mount, then measures. A missing target at runtime falls + * back to a centred-screen tooltip so the tour never blocks. */ -/** Stable IDs for every stop in the tour. */ +import type { ModuleKey } from "@/lib/modules/registry"; + +/** Stable IDs for every stop in the tour, in presentation order. */ export type TourStopId = - | "tile-strip" - | "quick-add" + | "dashboardOverview" + | "quickAdd" + | "measurements" + | "medications" + | "labs" + | "illness" + | "vorsorge" + | "cycle" + | "mood" | "insights" + | "coach" | "integrations" - | "achievements"; + | "export" + | "achievements" + | "wrapUp"; /** * One step in the tour. The `targetId` matches a `data-tour-id` on a - * live element somewhere in the app; the title/body i18n keys live - * under `onboarding.tour.steps..*`. + * live element; the title/body i18n keys live under + * `onboarding.tour.steps..*`. */ export interface TourStop { id: TourStopId; /** * The `data-tour-id` value of the element to anchor the spotlight - * to. May be `null` if the stop is purely informational and should - * render in a centred-screen tooltip without a spotlight cutout. + * to. `null` for a purely informational centred stop (the wrap-up). */ targetId: string | null; /** - * Preferred placement of the tooltip relative to the target. The UI - * layer is allowed to flip this if the target is too close to a - * viewport edge. + * Preferred tooltip placement relative to the target. The UI layer + * may flip it if the target is too close to a viewport edge. */ placement: "top" | "bottom" | "left" | "right" | "center"; /** i18n key for the step title. */ titleKey: string; /** i18n key for the step body. */ bodyKey: string; + /** + * v1.18.6 — the route this stop's anchor lives on. Absent ⇒ the + * anchor is on the current page (the wrap-up centred stop). When set, + * the overlay navigates there before measuring. `"/"` is explicit for + * the two dashboard stops so the resume path can route back from any + * page. + */ + route?: string; + /** + * v1.18.6 — module gate. When set, the stop is dropped unless the + * account's resolved module map has the key enabled (default-on: + * only an explicit `false` drops it, mirroring the nav gate). + */ + requiresModule?: ModuleKey; } /** - * Build the canonical tour-step list. Caller may inject a feature - * flag — e.g. `includeAchievements: false` if B4 hasn't shipped yet - * — and we omit that stop without renumbering. + * v1.18.6 — the canonical 15-stop module tour. Each entry is a literal + * so the order, anchors, routes and gates are one auditable list. + */ +const ALL_STOPS: readonly TourStop[] = [ + { + id: "dashboardOverview", + targetId: "dashboard-tile-strip", + placement: "bottom", + route: "/", + titleKey: "onboarding.tour.steps.dashboardOverview.title", + bodyKey: "onboarding.tour.steps.dashboardOverview.body", + }, + { + id: "quickAdd", + targetId: "dashboard-quick-add", + placement: "bottom", + route: "/", + titleKey: "onboarding.tour.steps.quickAdd.title", + bodyKey: "onboarding.tour.steps.quickAdd.body", + }, + { + id: "measurements", + targetId: "measurements-hero", + placement: "bottom", + route: "/measurements", + titleKey: "onboarding.tour.steps.measurements.title", + bodyKey: "onboarding.tour.steps.measurements.body", + }, + { + id: "medications", + targetId: "medications-hero", + placement: "bottom", + route: "/medications", + requiresModule: "medications", + titleKey: "onboarding.tour.steps.medications.title", + bodyKey: "onboarding.tour.steps.medications.body", + }, + { + id: "labs", + targetId: "labs-hero", + placement: "bottom", + route: "/labs", + requiresModule: "labs", + titleKey: "onboarding.tour.steps.labs.title", + bodyKey: "onboarding.tour.steps.labs.body", + }, + { + id: "illness", + targetId: "illness-hero", + placement: "bottom", + route: "/illness", + requiresModule: "illness", + titleKey: "onboarding.tour.steps.illness.title", + bodyKey: "onboarding.tour.steps.illness.body", + }, + { + id: "vorsorge", + targetId: "vorsorge-hero", + placement: "bottom", + route: "/vorsorge", + titleKey: "onboarding.tour.steps.vorsorge.title", + bodyKey: "onboarding.tour.steps.vorsorge.body", + }, + { + id: "cycle", + targetId: "cycle-hero", + placement: "bottom", + route: "/cycle", + requiresModule: "cycle", + titleKey: "onboarding.tour.steps.cycle.title", + bodyKey: "onboarding.tour.steps.cycle.body", + }, + { + id: "mood", + targetId: "mood-hero", + placement: "bottom", + route: "/mood", + requiresModule: "mood", + titleKey: "onboarding.tour.steps.mood.title", + bodyKey: "onboarding.tour.steps.mood.body", + }, + { + id: "insights", + targetId: "insights-hero", + placement: "bottom", + route: "/insights", + requiresModule: "insights", + titleKey: "onboarding.tour.steps.insights.title", + bodyKey: "onboarding.tour.steps.insights.body", + }, + { + id: "coach", + targetId: "coach-hero", + placement: "bottom", + route: "/coach", + requiresModule: "coach", + titleKey: "onboarding.tour.steps.coach.title", + bodyKey: "onboarding.tour.steps.coach.body", + }, + { + id: "integrations", + targetId: "integrations-hero", + placement: "bottom", + route: "/settings/integrations", + titleKey: "onboarding.tour.steps.integrations.title", + bodyKey: "onboarding.tour.steps.integrations.body", + }, + { + id: "export", + targetId: "export-hero", + placement: "bottom", + route: "/settings/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>; + +/** + * 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, + }; +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index dbab3937c..0ac6f7361 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...labsPaths, ...biomarkerPaths, ...illnessPaths, + ...onboardingPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts new file mode 100644 index 000000000..efdc1b4cf --- /dev/null +++ b/src/lib/openapi/routes/onboarding.ts @@ -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 = { + "/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, + }, + }, + }, +};