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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 23:58:27 +02:00
1 parent f67542d29e
commit 3ba542f797
24 files changed
+41 -575

No files matched your search

+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
+1 -5
View File
@@ -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",
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="achievements" />
</div>
</div>
-2
View File
@@ -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={
<>
<ModuleTourTrigger stopId="coach" />
<Button
type="button"
variant="ghost"
-2
View File
@@ -16,7 +16,6 @@ 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();
@@ -68,7 +67,6 @@ export default function LabsPage() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="labs" />
{/* 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
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="measurements" />
<Button
className="min-h-11 sm:min-h-9"
onClick={() => setDialogOpen(true)}
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="medications" />
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
permanent fixture: it renders only while at least TWO
medications are currently due (a single due dose is the
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="mood" />
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="cycle" />
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
@@ -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}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
<ModuleTourTrigger stopId="dashboardOverview" />
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
-2
View File
@@ -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() {
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModuleTourTrigger stopId="illness" />
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<Button
@@ -19,7 +19,6 @@ 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 {
@@ -863,7 +862,6 @@ function InsightsTabStripImpl({
{/* v1.18.6 — per-module tour re-entry, travelling with the
top-right control cluster. Anchors to this strip's
`insights-hero` and opens the Insights tour card on the spot. */}
<ModuleTourTrigger stopId="insights" />
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
regenerate button and links to the Insights settings section
(overview arrange + pill sort), matching the Dashboard cog
@@ -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 `<TourLauncher>`'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<string, string>;
const originalWindow = (globalThis as { window?: unknown }).window;
beforeEach(() => {
storage = new Map<string, string>();
(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();
});
});
@@ -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);
});
});
@@ -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 `<TourLauncher>` picks it up and
* opens the spotlight narrowed to that single module card on the
* current page (no cross-page navigation, no completion flip).
*
* Anchors to the page's `data-tour-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 (
<Button
type="button"
variant="ghost"
size="sm"
onClick={show}
// 44px mobile tap floor, matching the rest of the module headers.
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
data-testid={`module-tour-trigger-${stopId}`}
>
<Compass className="h-4 w-4" />
<span className="sr-only sm:not-sr-only">
{t("onboarding.tour.moduleTrigger")}
</span>
</Button>
);
}
+15 -124
View File
@@ -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<TourStopId | null>(null);
// Track post-wizard deferral as a boolean so the render path stays
// pure (no Date.now() comparisons). The effect below reads / clears it.
const [deferredFromWizard, setDeferredFromWizard] = useState(false);
@@ -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 (
<OnboardingTour
modules={modules}
resumeFromStopId={resumeFromStopId}
filterToStop={singleStop ?? undefined}
// Fire-and-forget checkpoint so a mid-tour reload resumes at the
// right module. Skipped implicitly by the overlay for the single-
// stop re-entry. A failed write is non-fatal — the resume point is
// right module. A failed write is non-fatal — the resume point is
// a convenience, not a correctness invariant.
onProgress={handleProgress}
onClose={async (outcome) => {
@@ -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 });
@@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.");
});
it("<AdvancedSection> exposes the single Restart-tour card", () => {
// v1.18.1 (D1) — the tour-replay card moved from Account into Advanced.
// v1.18.6 — relabelled "Restart module tour" and consolidated as the one
// full-tour replay home (the About duplicate was removed).
it("<AdvancedSection> 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(<AdvancedSection />);
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("<AccountSection> no longer carries the Restart-tour button", () => {
+4 -84
View File
@@ -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
// `<SettingsSectionFrame>` 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 (
<div className="space-y-6">
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
<TourReplayCard />
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
@@ -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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("onboarding.tour.restart")}
</h2>
<p className="text-muted-foreground text-xs">
{t("onboarding.tour.restartHint")}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={handleRestartTour}
disabled={restarting}
data-testid="settings-restart-tour"
className="w-full shrink-0 sm:w-auto"
>
{restarting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="h-4 w-4" />
)}
{t("onboarding.tour.restart")}
</Button>
</div>
{feedback && (
<p
role="alert"
className={`mt-2 text-xs ${
feedback.type === "success" ? "text-success" : "text-destructive"
}`}
>
{t(feedback.key)}
</p>
)}
</div>
);
}
/**
* 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 /
@@ -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<typeof fetch>()
.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<typeof fetch>()
.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<typeof fetch>()
.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<typeof fetch>().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();
});
});
-83
View File
@@ -1,83 +0,0 @@
/**
* v1.4.48 M6b — shared "restart onboarding tour" worker.
*
* `<AccountSection>` and `<AboutSection>` both expose a button that
* resets `users.onboarding_tour_completed = false` and arranges for the
* dashboard's `<TourLauncher>` 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<RestartTourResult> {
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" };
}
}