From 3ba542f7970cdab59fd125796ab6ac10951ee033 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 23:58:27 +0200
Subject: [PATCH] fix(onboarding): make the guided tour first-time-only, drop
every re-entry trigger
The guided tour now exists only as the first-time auto-start after onboarding.
Remove every in-app trigger that re-opened it: the per-module "show this tour"
button on the dashboard, measurements, mood, medications, labs, illness, cycle,
achievements, insights, and coach surfaces; and the Settings replay card.
Delete the now-orphaned ModuleTourTrigger component, the tour-restart worker,
the healthlog:module-tour and healthlog:tour-restart event listeners, the
single-stop overlay path, and the force-launch session marker. The first-time
auto-launch gate and the tour overlay itself are unchanged. Prune the orphaned
i18n keys across all locales and update the affected tests.
---
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/pl.json | 6 +-
src/app/achievements/page.tsx | 2 -
src/app/coach/page.tsx | 2 -
src/app/labs/page.tsx | 2 -
src/app/measurements/page.tsx | 2 -
src/app/medications/page.tsx | 2 -
src/app/mood/page.tsx | 2 -
src/components/cycle/cycle-view.tsx | 2 -
src/components/dashboard/dashboard-header.tsx | 3 -
src/components/illness/illness-view.tsx | 2 -
.../insights/insights-tab-strip.tsx | 2 -
.../__tests__/tour-launcher-gate.test.ts | 76 +---------
.../__tests__/tour-launcher-keys.test.ts | 28 +---
.../onboarding/module-tour-trigger.tsx | 53 -------
src/components/onboarding/tour-launcher.tsx | 139 ++----------------
.../settings/__tests__/sections.test.tsx | 15 +-
src/components/settings/advanced-section.tsx | 88 +----------
.../onboarding/__tests__/tour-restart.test.ts | 77 ----------
src/lib/onboarding/tour-restart.ts | 83 -----------
24 files changed, 41 insertions(+), 575 deletions(-)
delete mode 100644 src/components/onboarding/module-tour-trigger.tsx
delete mode 100644 src/lib/onboarding/__tests__/tour-restart.test.ts
delete mode 100644 src/lib/onboarding/tour-restart.ts
diff --git a/messages/de.json b/messages/de.json
index 321cf3dc0..da00a7739 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3552,9 +3552,6 @@
"back": "Zurück",
"skip": "Tour überspringen",
"done": "Verstanden",
- "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": {
"dashboardOverview": {
"title": "Dein Dashboard",
@@ -3616,8 +3613,7 @@
"title": "Das war die Tour",
"body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
}
- },
- "moduleTrigger": "Diese Tour zeigen"
+ }
},
"shell": {
"back": "Zurück",
diff --git a/messages/en.json b/messages/en.json
index 2dd3fb4f3..4a9917e07 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3552,9 +3552,6 @@
"back": "Back",
"skip": "Skip tour",
"done": "Got it",
- "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": {
"dashboardOverview": {
"title": "Your dashboard",
@@ -3616,8 +3613,7 @@
"title": "That’s the tour",
"body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
}
- },
- "moduleTrigger": "Show this tour"
+ }
},
"shell": {
"back": "Back",
diff --git a/messages/es.json b/messages/es.json
index e5a4a8a3d..d922014dd 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3552,9 +3552,6 @@
"back": "Atrás",
"skip": "Saltar tour",
"done": "Entendido",
- "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": {
"dashboardOverview": {
"title": "Tu panel",
@@ -3616,8 +3613,7 @@
"title": "Esto es la visita",
"body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
}
- },
- "moduleTrigger": "Mostrar esta visita"
+ }
},
"shell": {
"back": "Atrás",
diff --git a/messages/fr.json b/messages/fr.json
index a9b43fc82..4837f53e2 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3552,9 +3552,6 @@
"back": "Précédent",
"skip": "Passer la visite",
"done": "Compris",
- "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": {
"dashboardOverview": {
"title": "Votre tableau de bord",
@@ -3616,8 +3613,7 @@
"title": "C’est la fin de la visite",
"body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
}
- },
- "moduleTrigger": "Afficher cette visite"
+ }
},
"shell": {
"back": "Retour",
diff --git a/messages/it.json b/messages/it.json
index 672aaa46a..a285b0228 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3552,9 +3552,6 @@
"back": "Indietro",
"skip": "Salta tour",
"done": "Ho capito",
- "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": {
"dashboardOverview": {
"title": "La tua dashboard",
@@ -3616,8 +3613,7 @@
"title": "Questa è la visita guidata",
"body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
}
- },
- "moduleTrigger": "Mostra questa visita"
+ }
},
"shell": {
"back": "Indietro",
diff --git a/messages/pl.json b/messages/pl.json
index b4ccb8202..8d176eccc 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3552,9 +3552,6 @@
"back": "Wstecz",
"skip": "Pomiń tour",
"done": "Rozumiem",
- "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": {
"dashboardOverview": {
"title": "Twój pulpit",
@@ -3616,8 +3613,7 @@
"title": "To koniec przewodnika",
"body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
}
- },
- "moduleTrigger": "Pokaż ten przewodnik"
+ }
},
"shell": {
"back": "Wstecz",
diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx
index f0a9448c3..98dae02ee 100644
--- a/src/app/achievements/page.tsx
+++ b/src/app/achievements/page.tsx
@@ -34,7 +34,6 @@ 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 {
@@ -331,7 +330,6 @@ export default function AchievementsPage() {
-
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx
index 9ffb5a694..49e130fb6 100644
--- a/src/app/coach/page.tsx
+++ b/src/app/coach/page.tsx
@@ -6,7 +6,6 @@ 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";
@@ -100,7 +99,6 @@ export default function CoachPage() {
)}
leadingHeaderActions={
<>
-
-
{/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 7e72ff863..57fc95791 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -16,7 +16,6 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -125,7 +124,6 @@ export default function MeasurementsPage() {
-
setDialogOpen(true)}
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index 7aa906b6c..002763a41 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
@@ -342,7 +341,6 @@ 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 9a8485974..645b183f5 100644
--- a/src/app/mood/page.tsx
+++ b/src/app/mood/page.tsx
@@ -15,7 +15,6 @@ 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();
@@ -79,7 +78,6 @@ 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/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 381316336..74e6b9bea 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
@@ -151,7 +150,6 @@ export function CycleView() {
-
openSheet(today)}
className="min-h-11 sm:min-h-9"
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 54ce87021..79aa15331 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -23,7 +23,6 @@ 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";
@@ -97,8 +96,6 @@ 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 7af11279d..d3722301e 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -33,7 +33,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { LogDaySheet } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
@@ -380,7 +379,6 @@ export function IllnessView() {
-
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
regenerate button and links to the Insights settings section
(overview arrange + pill sort), matching the Dashboard cog
diff --git a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
index 320fea350..870803ba7 100644
--- a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
+++ b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts
@@ -10,20 +10,15 @@
* The fix turns the tour into "the second-visit thing": auto-launch
* is suppressed until `onboardingCompletedAt + 24 h`. This file pins
* the pure decision helper so a future refactor doesn't silently
- * regress the gate. The "manual restart from Settings" path bypasses
- * the gate entirely and lives in `
`'s
- * `healthlog:tour-restart` window event listener — exercised by the
- * sections SSR smoke test (`settings/__tests__/sections.test.tsx`) +
- * the e2e suite, not by this file.
+ * regress the gate. v1.18.6.1 — the tour is first-time-auto-start
+ * only; the former "manual restart from Settings" bypass was removed.
*/
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
import {
- setTourForceLaunch,
shouldAutoLaunchTour,
tourIncludesAchievements,
TOUR_AUTOLAUNCH_DELAY_MS,
- tourForceLaunchKey,
} from "../tour-launcher";
const NOW = Date.parse("2026-05-21T12:00:00Z");
@@ -153,68 +148,3 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
).toBe(true);
});
});
-
-describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => {
- // (c) of the W5 acceptance: the manual "launch tour" trigger still
- // works. The launcher's render-phase gate is exercised by
- // shouldAutoLaunchTour() above; the *bypass* is a sessionStorage
- // marker the Settings buttons drop and the launcher reads-and-
- // clears on its next mount. Vitest runs in the Node environment by
- // default — stub a minimal window so the helper's window-guard +
- // try/catch wrappers can be exercised end-to-end.
- let storage: Map;
- const originalWindow = (globalThis as { window?: unknown }).window;
-
- beforeEach(() => {
- storage = new Map();
- (globalThis as { window?: unknown }).window = {
- sessionStorage: {
- getItem: (key: string) => storage.get(key) ?? null,
- setItem: (key: string, value: string) => {
- storage.set(key, value);
- },
- removeItem: (key: string) => {
- storage.delete(key);
- },
- },
- };
- });
-
- afterEach(() => {
- if (originalWindow === undefined) {
- delete (globalThis as { window?: unknown }).window;
- } else {
- (globalThis as { window?: unknown }).window = originalWindow;
- }
- });
-
- it("writes the per-user force-launch marker into sessionStorage", () => {
- // The Settings → About + Settings → Account "Replay tour" buttons
- // call this exact helper after the API round-trip succeeds. The
- // launcher reads-and-clears the marker on its next dashboard
- // mount, bypassing the 24 h auto-launch gate so a same-day user
- // who explicitly asked for the tour actually gets it.
- setTourForceLaunch("u_replay_demo");
- expect(storage.get(tourForceLaunchKey("u_replay_demo"))).toBe("1");
- });
-
- it("scopes the marker per user (two users do not bleed into each other)", () => {
- setTourForceLaunch("u_alice");
- setTourForceLaunch("u_bob");
- expect(storage.get(tourForceLaunchKey("u_alice"))).toBe("1");
- expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
- // The two keys are independent slots — clearing one (which the
- // launcher does on mount) must not affect the other.
- storage.delete(tourForceLaunchKey("u_alice"));
- expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
- });
-
- it("does not throw when window is undefined (SSR safety)", () => {
- // The launcher itself is "use client" but the helper is exported
- // from the same module and could be imported by a server component
- // in the future. The window-guard inside the helper has to keep
- // SSR happy — verify it stays a silent no-op.
- delete (globalThis as { window?: unknown }).window;
- expect(() => setTourForceLaunch("u_x")).not.toThrow();
- });
-});
diff --git a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
index cc51c0dae..4208c3296 100644
--- a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
+++ b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts
@@ -12,16 +12,12 @@
* accept that one-off tax in exchange for the multi-tenant
* correctness.
*
- * v1.4.47 W5 adds a third key, `tourForceLaunchKey`, that carries
- * the "user just clicked Replay in Settings → About/Account" intent
- * across the navigation back to the dashboard. The launcher reads
- * and clears it on its next mount, bypassing the new 24 h
- * auto-launch gate so a first-day user who *wants* the tour
- * actually gets it.
+ * v1.18.6.1 — the tour is first-time-auto-start only; the former
+ * `tourForceLaunchKey` replay-bypass key was removed along with every
+ * in-app tour re-entry trigger.
*/
import { describe, expect, it } from "vitest";
import {
- tourForceLaunchKey,
tourReferrerKey,
tourSessionDismissedKey,
} from "../tour-launcher";
@@ -37,20 +33,11 @@ describe("tour-launcher sessionStorage key scoping", () => {
expect(tourReferrerKey("u_abc")).toBe("healthlog-tour-referrer:u_abc");
});
- it("scopes the force-launch key by user id (v1.4.47 W5)", () => {
- expect(tourForceLaunchKey("u_abc")).toBe(
- "healthlog-tour-force-launch:u_abc",
- );
- });
-
it("returns distinct keys for distinct users", () => {
expect(tourSessionDismissedKey("u_alice")).not.toBe(
tourSessionDismissedKey("u_bob"),
);
expect(tourReferrerKey("u_alice")).not.toBe(tourReferrerKey("u_bob"));
- expect(tourForceLaunchKey("u_alice")).not.toBe(
- tourForceLaunchKey("u_bob"),
- );
});
it("does not collide with the legacy un-scoped keys", () => {
@@ -58,20 +45,17 @@ describe("tour-launcher sessionStorage key scoping", () => {
"healthlog-tour-session-dismissed",
);
expect(tourReferrerKey("u_x")).not.toBe("healthlog-tour-referrer");
- expect(tourForceLaunchKey("u_x")).not.toBe("healthlog-tour-force-launch");
});
- it("uses three distinct namespaces so the keys can never alias each other", () => {
+ it("uses distinct namespaces so the keys can never alias each other", () => {
// Belt-and-braces: if a future refactor accidentally collapses
// two key builders onto the same prefix, the launcher's gating
- // logic would conflate "session-dismissed" with "force-launch"
- // and produce a one-shot tour-loop on every dashboard mount.
+ // logic would conflate "session-dismissed" with "referrer".
const userId = "u_test";
const keys = new Set([
tourSessionDismissedKey(userId),
tourReferrerKey(userId),
- tourForceLaunchKey(userId),
]);
- expect(keys.size).toBe(3);
+ expect(keys.size).toBe(2);
});
});
diff --git a/src/components/onboarding/module-tour-trigger.tsx b/src/components/onboarding/module-tour-trigger.tsx
deleted file mode 100644
index 717a3fa01..000000000
--- a/src/components/onboarding/module-tour-trigger.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"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-id="<…>-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 (
-
-
-
- {t("onboarding.tour.moduleTrigger")}
-
-
- );
-}
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index c76b6c0d1..02a7e5869 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -5,8 +5,9 @@
*
* Decides *when* to start the spotlight tour. Mounted on the
* dashboard; opens the tour exactly once for new users and never
- * again unless they explicitly request a replay from
- * Settings → Account or Settings → About.
+ * again. v1.18.6.1 — the tour exists ONLY as this first-time
+ * auto-start: every in-app re-entry trigger (the per-module "show
+ * this tour" buttons and the Settings replay card) was removed.
*
* Gating rules:
*
@@ -34,17 +35,8 @@
* before the user gets to touch the dashboard. The tour now
* auto-launches only after the wizard has been finished at
* least 24 h ago. Brand-new users (`onboardingCompletedAt ==
- * null`) never see the auto-launch — they can still trigger
- * the tour manually from Settings → About or Settings →
- * Account.
- *
- * Replay flow: the Settings → Account "Restart" button (and the
- * Settings → About "Replay" button added in v1.4.47 W5) POST
- * `{ completed: false }` to /api/onboarding/tour, then dispatch a
- * `healthlog:tour-restart` window event. This launcher listens for
- * that event and force-opens the tour ignoring the auto-launch gate
- * (24 h delay, session-dismissed flag, post-wizard grace) so the
- * manual replay always works.
+ * null`) never see the auto-launch on the same session they
+ * finished onboarding.
*/
import { usePathname } from "next/navigation";
@@ -53,7 +45,6 @@ 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;
@@ -148,48 +139,6 @@ export function tourReferrerKey(userId: string): string {
return `healthlog-tour-referrer:${userId}`;
}
-/**
- * v1.4.47 W5 — sessionStorage key used by the Settings → About
- * "Replay the tour" + Settings → Account "Restart onboarding tour"
- * buttons to bypass the 24 h auto-launch gate on the next dashboard
- * mount. Without this, a first-day user who manually clicks the
- * replay button would still hit the 24 h block after navigating to
- * the dashboard (because the launcher mounts fresh and re-evaluates
- * the auto-launch gate). Exported for the unit-test suite + so the
- * key construction stays pinned across refactors.
- */
-export function tourForceLaunchKey(userId: string): string {
- return `healthlog-tour-force-launch:${userId}`;
-}
-
-/**
- * Write the force-launch marker. Called by both Settings → Account
- * and Settings → About when the user explicitly requests the tour.
- * Exported so the Settings buttons can call it without re-implementing
- * the sessionStorage dance (and so the test suite can stub it).
- */
-export function setTourForceLaunch(userId: string): void {
- if (typeof window === "undefined") return;
- try {
- window.sessionStorage.setItem(tourForceLaunchKey(userId), "1");
- } catch {
- /* ignore — sandboxed iframes etc. */
- }
-}
-
-function readAndClearForceLaunch(userId: string): boolean {
- if (typeof window === "undefined") return false;
- try {
- const key = tourForceLaunchKey(userId);
- const value = window.sessionStorage.getItem(key);
- if (value !== "1") return false;
- window.sessionStorage.removeItem(key);
- return true;
- } catch {
- return false;
- }
-}
-
/**
* Whether the user just navigated here from `/onboarding`. Read
* once at mount via a `useState` lazy initialiser (which runs in a
@@ -253,15 +202,6 @@ function writeSessionDismissed(userId: string) {
}
}
-function clearSessionDismissed(userId: string) {
- if (typeof window === "undefined") return;
- try {
- window.sessionStorage.removeItem(tourSessionDismissedKey(userId));
- } catch {
- /* ignore */
- }
-}
-
interface TourLauncherProps {
/**
* v1.18.6 — optional readiness sentinel. The dashboard used to pass
@@ -287,7 +227,6 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
// 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);
@@ -347,17 +286,9 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
// render body). The `decidedFor` tuple above further guarantees
// this branch runs at most once per (user, flag) transition.
//
- // Force-launch bypass: when the user explicitly requests the tour
- // from Settings → About / Settings → Account, those buttons set a
- // per-user sessionStorage marker via `setTourForceLaunch()`. The
- // launcher reads-and-clears the marker here so the tour launches
- // on the very next dashboard mount — independent of the 24 h gate,
- // the session-dismissed flag, and the post-wizard grace. The
- // marker survives navigation but not a page reload, which matches
- // user expectation ("I clicked replay, now show me the tour").
- if (readAndClearForceLaunch(user.id)) {
- setShowTour(true);
- } else if (
+ // v1.18.6.1 — the tour is now first-time-auto-start only; there is
+ // no in-app replay trigger and therefore no force-launch bypass.
+ if (
!shouldAutoLaunchTour({
onboardingTourCompleted: user.onboardingTourCompleted,
onboardingCompletedAt: user.onboardingCompletedAt,
@@ -396,39 +327,10 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
return () => window.clearTimeout(id);
}, [deferredFromWizard, user?.id]);
- // 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);
- return () => {
- window.removeEventListener("healthlog:tour-restart", onRestart);
- };
- }, [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.1 — the guided tour exists ONLY as the first-time auto-start
+ // after onboarding. The per-module "show this tour" re-entry and the
+ // Settings replay card were removed, so there is no longer any in-app
+ // trigger that re-opens the overlay.
// v1.18.6 — stable progress callback so the overlay's checkpoint effect
// fires on a real step transition, not on every launcher re-render.
@@ -446,20 +348,15 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
// 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);
+ // v1.18.6 — resume the full tour from the persisted checkpoint.
+ const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null;
return (
{
@@ -470,13 +367,7 @@ export function TourLauncher({ ready = true }: 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/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 34ac19d7e..296ce813a 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.");
});
- 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).
+ it(" no longer carries a tour-replay card", () => {
+ // v1.18.6.1 — the guided tour exists only as the first-time auto-start
+ // after onboarding. Every in-app replay / re-entry trigger was removed,
+ // including this Advanced card.
const html = render( );
- expect(html).toContain('data-testid="settings-restart-tour"');
- expect(html).toContain("Restart module tour");
- // No raw i18n key leaks.
- expect(html).not.toContain("onboarding.tour.");
+ expect(html).not.toContain('data-testid="settings-restart-tour"');
+ expect(html).not.toContain("Restart module tour");
+ expect(html).not.toContain("onboarding.tour.restart");
});
it(" no longer carries the Restart-tour button", () => {
diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx
index b8710d71f..7650cefee 100644
--- a/src/components/settings/advanced-section.tsx
+++ b/src/components/settings/advanced-section.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { BookOpenCheck, Compass, Loader2, Trash2 } from "lucide-react";
+import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -20,9 +20,7 @@ import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
-import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
import {
type ResearchModeStatus,
@@ -53,10 +51,11 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
export function AdvancedSection() {
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `` in the route; this body is the advanced cards.
+ // v1.18.6.1 — the guided tour replay card was removed: the tour now exists
+ // only as the first-time auto-start after onboarding. This page is the
+ // research-mode + data/account destructive controls.
return (
- {/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
-
@@ -64,85 +63,6 @@ export function AdvancedSection() {
);
}
-/**
- * v1.18.1 (D1) — onboarding-tour replay, relocated from Settings → Account.
- *
- * Resets `users.onboarding_tour_completed` on the server and dispatches a
- * `healthlog:tour-restart` window event so a dashboard already in the
- * background reopens the spotlight tour immediately. Delegates to the shared
- * `restartOnboardingTour()` worker (the same one Settings → About uses) and
- * refetches the auth payload so the launcher's `onboardingTourCompleted` flag
- * matches the server flip without a navigation.
- */
-function TourReplayCard() {
- const { t } = useTranslations();
- const { user, refetch } = useAuth();
- const [restarting, setRestarting] = useState(false);
- const [feedback, setFeedback] = useState<{
- key: string;
- type: "success" | "error";
- } | null>(null);
-
- async function handleRestartTour() {
- setRestarting(true);
- setFeedback(null);
- const result = await restartOnboardingTour(user?.id);
- if (result.ok) {
- await refetch();
- setFeedback({
- key: "onboarding.tour.restartConfirmation",
- type: "success",
- });
- } else {
- setFeedback({ key: result.messageKey, type: "error" });
- }
- setRestarting(false);
- }
-
- return (
-
-
-
-
-
-
- {t("onboarding.tour.restart")}
-
-
- {t("onboarding.tour.restartHint")}
-
-
-
-
- {restarting ? (
-
- ) : (
-
- )}
- {t("onboarding.tour.restart")}
-
-
- {feedback && (
-
- {t(feedback.key)}
-
- )}
-
- );
-}
-
/**
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
* the toggle's caption out of the JSX. The four-way ladder (loading /
diff --git a/src/lib/onboarding/__tests__/tour-restart.test.ts b/src/lib/onboarding/__tests__/tour-restart.test.ts
deleted file mode 100644
index fb5b0c08b..000000000
--- a/src/lib/onboarding/__tests__/tour-restart.test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// Stub the launcher import so the helper test never has to instantiate
-// the @dnd-kit / launcher render tree just to exercise its
-// sessionStorage write side-effect. We assert the helper *calls* it on
-// the success branch and skips it when no user-id was passed in.
-const setTourForceLaunchSpy = vi.fn();
-vi.mock("@/components/onboarding/tour-launcher", () => ({
- setTourForceLaunch: (userId: string) => setTourForceLaunchSpy(userId),
-}));
-
-import { restartOnboardingTour } from "../tour-restart";
-
-describe("restartOnboardingTour()", () => {
- beforeEach(() => {
- setTourForceLaunchSpy.mockClear();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it("success branch — flips the server flag, fires the force-launch marker, returns { ok: true }", async () => {
- const fetchImpl = vi
- .fn
()
- .mockResolvedValue(new Response("{}", { status: 200 }));
-
- const result = await restartOnboardingTour("user-42", { fetchImpl });
-
- expect(result).toEqual({ ok: true });
- expect(fetchImpl).toHaveBeenCalledTimes(1);
- const [url, init] = fetchImpl.mock.calls[0];
- expect(url).toBe("/api/onboarding/tour");
- expect(init?.method).toBe("POST");
- expect(init?.headers).toEqual({ "Content-Type": "application/json" });
- expect(init?.body).toBe(JSON.stringify({ completed: false }));
- // Per-user force-launch marker fired on success.
- expect(setTourForceLaunchSpy).toHaveBeenCalledWith("user-42");
- });
-
- it("success branch — skips the force-launch marker when no userId was supplied", async () => {
- const fetchImpl = vi
- .fn()
- .mockResolvedValue(new Response("{}", { status: 200 }));
-
- const result = await restartOnboardingTour(undefined, { fetchImpl });
-
- expect(result).toEqual({ ok: true });
- expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
- });
-
- it("error-network branch — fetch rejects, returns { ok: false } with common.networkError", async () => {
- const fetchImpl = vi
- .fn()
- .mockRejectedValue(new TypeError("network down"));
-
- const result = await restartOnboardingTour("user-42", { fetchImpl });
-
- expect(result).toEqual({ ok: false, messageKey: "common.networkError" });
- // No force-launch marker should fire on the error branch — a failed
- // server flip MUST NOT arm the launcher.
- expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
- });
-
- it("error-server-422 branch — non-ok response returns { ok: false } with settings.savingError", async () => {
- const fetchImpl = vi.fn().mockResolvedValue(
- new Response(JSON.stringify({ error: "Unprocessable" }), {
- status: 422,
- }),
- );
-
- const result = await restartOnboardingTour("user-42", { fetchImpl });
-
- expect(result).toEqual({ ok: false, messageKey: "settings.savingError" });
- expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
- });
-});
diff --git a/src/lib/onboarding/tour-restart.ts b/src/lib/onboarding/tour-restart.ts
deleted file mode 100644
index 75c395c8e..000000000
--- a/src/lib/onboarding/tour-restart.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * v1.4.48 M6b — shared "restart onboarding tour" worker.
- *
- * `` and `` both expose a button that
- * resets `users.onboarding_tour_completed = false` and arranges for the
- * dashboard's `` to re-open the spotlight on the user's
- * next navigation. Until v1.4.48 the two handlers were 90 % identical
- * copy-paste — a known drift hazard. This helper owns the surface so
- * both call sites narrow to a single mutate-then-route step:
- *
- * const result = await restartOnboardingTour(user?.id);
- * if (result.ok) toast(t("onboarding.tour.restartConfirmation"));
- * else toast(t(result.messageKey));
- *
- * Side-effects this helper owns:
- *
- * - PUT `/api/onboarding/tour` with `{ completed: false }` (the same
- * server flip both Account and About already drove).
- * - On success — write the per-user `setTourForceLaunch(userId)`
- * marker into sessionStorage so the next dashboard mount opens
- * the tour even when the 24 h auto-launch gate would suppress it.
- * - Fire a `healthlog:tour-restart` window event so a dashboard
- * mounted in another tab / background reopens the spotlight
- * immediately. The dispatch is best-effort; SSR or sandboxed
- * iframes that can't construct CustomEvent are tolerated.
- *
- * Translation + toast rendering are deliberately NOT owned here — the
- * helper returns a discriminated `{ ok }` result and lets the React
- * component pick the right `useTranslations()` instance. Keeps the
- * helper pure of i18n state and easy to unit-test under the default
- * vitest "node" environment.
- */
-
-import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
-
-export type RestartTourResult =
- | { ok: true }
- | { ok: false; messageKey: string };
-
-export interface RestartTourOptions {
- /**
- * Fetch implementation injected for tests. Defaults to the global
- * `fetch` so production code paths read exactly as before.
- */
- fetchImpl?: typeof fetch;
-}
-
-/**
- * Persist the tour-replay request server-side and arm the launcher
- * for the user's next dashboard navigation.
- *
- * `userId` may be undefined when the auth payload hasn't landed yet —
- * the server flip still fires, and the force-launch marker is simply
- * skipped (the launcher's secondary "tour-completed = false" path
- * still picks the change up on the next `/api/auth/me` read).
- */
-export async function restartOnboardingTour(
- userId: string | undefined,
- opts: RestartTourOptions = {},
-): Promise {
- const fetchImpl = opts.fetchImpl ?? fetch;
- try {
- const res = await fetchImpl("/api/onboarding/tour", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ completed: false }),
- });
- if (!res.ok) {
- return { ok: false, messageKey: "settings.savingError" };
- }
- if (userId) setTourForceLaunch(userId);
- try {
- if (typeof window !== "undefined") {
- window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
- }
- } catch {
- /* ignore — only matters if a dashboard is already mounted */
- }
- return { ok: true };
- } catch {
- return { ok: false, messageKey: "common.networkError" };
- }
-}