feat(onboarding): defer dashboard tour to second visit + add About replay button

WelcomeCarousel and TourLauncher both auto-launched when their gating
flags were null, stacking ~90 seconds of forced onboarding on top of a
brand-new user before they could touch the dashboard. The tour now
auto-launches only ≥ 24 h after the wizard finishes (new pure
`shouldAutoLaunchTour` helper); first-day visits belong to the welcome
carousel alone. Users who *want* the tour on day one can trigger it
from a new "Replay the tour" / "Tour ansehen" button in Settings →
About — same persistence + window-event flow as the existing Settings
→ Account restart button. A per-user `tour-force-launch` sessionStorage
marker carries the manual intent across the navigation back to the
dashboard so the 24 h gate stays out of the way of an explicit click.
This commit is contained in:
Marc-André Bombeck committed 2026-05-22 09:16:30 +02:00
1 parent 76e62283fa
commit 5a3b8b142d
12 files changed
+540 -15

No files matched your search

+3 -1
View File
@@ -1996,7 +1996,9 @@
"changelog": "Changelog",
"docs": "Dokumentation",
"linksHeading": "Quellen & Dokumentation",
"newerAvailable": "Neue Version: {tag}"
"newerAvailable": "Neue Version: {tag}",
"tourReplay": "Tour ansehen",
"tourReplayHint": "Zeige die Dashboard-Tour — praktisch, falls du sie beim ersten Besuch übersprungen hast."
},
"testConnection": {
"test": "Verbindung testen",
+3 -1
View File
@@ -1996,7 +1996,9 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
},
"testConnection": {
"test": "Test connection",
+3 -1
View File
@@ -1996,7 +1996,9 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
},
"testConnection": {
"test": "Test connection",
+3 -1
View File
@@ -1996,7 +1996,9 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
},
"testConnection": {
"test": "Test connection",
+3 -1
View File
@@ -1996,7 +1996,9 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
},
"testConnection": {
"test": "Test connection",
+3 -1
View File
@@ -1996,7 +1996,9 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
},
"testConnection": {
"test": "Test connection",
@@ -0,0 +1,198 @@
/**
* v1.4.47 W5 — onboarding chain gate.
*
* `<WelcomeCarousel>` (gated on `users.onboardingCompletedAt`) and
* `<TourLauncher>` (gated on `users.onboardingTourCompleted`) used to
* both auto-launch when null. A brand-new user finished the carousel
* and was immediately greeted by the spotlight tour — ~90 seconds of
* forced onboarding before they could touch the dashboard.
*
* 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.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
setTourForceLaunch,
shouldAutoLaunchTour,
TOUR_AUTOLAUNCH_DELAY_MS,
tourForceLaunchKey,
} from "../tour-launcher";
const NOW = Date.parse("2026-05-21T12:00:00Z");
const ONE_HOUR_MS = 60 * 60 * 1000;
describe("shouldAutoLaunchTour() — v1.4.47 W5 chain-gate", () => {
it("returns false when the user already completed (or dismissed) the tour", () => {
// Hard veto independent of timing — a user who already saw the
// tour should never see it again from the auto-launch path even
// if their wizard completion is years in the past.
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: true,
onboardingCompletedAt: new Date(NOW - 365 * 24 * ONE_HOUR_MS)
.toISOString(),
nowMs: NOW,
}),
).toBe(false);
});
it("returns false for brand-new users who have not finished the wizard yet", () => {
// (a) of the W5 acceptance: the tour does NOT mount in the same
// session as carousel completion. Concretely: the wizard hasn't
// POSTed step 4 yet (`onboardingCompletedAt` still null), so the
// 24 h clock has not started — auto-launch is suppressed.
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: false,
onboardingCompletedAt: null,
nowMs: NOW,
}),
).toBe(false);
});
it("returns false when the wizard finished less than 24 h ago", () => {
// Same-session and same-day re-visits both fall here. The
// welcome carousel owns the post-wizard first impression; the
// tour stays out of the way.
const oneHourAfterWizard = new Date(NOW - ONE_HOUR_MS).toISOString();
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: false,
onboardingCompletedAt: oneHourAfterWizard,
nowMs: NOW,
}),
).toBe(false);
// Boundary just inside the 24 h window — still suppressed.
const justUnder24h = new Date(
NOW - TOUR_AUTOLAUNCH_DELAY_MS + 1,
).toISOString();
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: false,
onboardingCompletedAt: justUnder24h,
nowMs: NOW,
}),
).toBe(false);
});
it("returns true on a synthetic mount ≥ 24 h after wizard completion", () => {
// (b) of the W5 acceptance: the tour DOES mount on a synthetic
// ≥ 24 h-later mount. Two cases bracket the boundary — exactly
// at the 24 h mark and a day later.
const exactly24hAfter = new Date(
NOW - TOUR_AUTOLAUNCH_DELAY_MS,
).toISOString();
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: false,
onboardingCompletedAt: exactly24hAfter,
nowMs: NOW,
}),
).toBe(true);
const twoDaysAfter = new Date(
NOW - 2 * TOUR_AUTOLAUNCH_DELAY_MS,
).toISOString();
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: false,
onboardingCompletedAt: twoDaysAfter,
nowMs: NOW,
}),
).toBe(true);
});
it("returns false when the timestamp string cannot be parsed", () => {
// Defensive: a malformed payload (e.g. accidental locale-string
// serialisation upstream) should never auto-launch the tour and
// surprise a brand-new user. Treat unparseable as "no completion".
expect(
shouldAutoLaunchTour({
onboardingTourCompleted: false,
onboardingCompletedAt: "not-a-real-iso-timestamp",
nowMs: NOW,
}),
).toBe(false);
});
it("uses a 24 h delay constant (locked exact value)", () => {
// Pin the constant so a future refactor that tries to drop the
// delay (e.g. "let's just enable it after one hour") has to
// confront this test. The number lives in the source and is
// re-exported for tests + for any future runtime introspection.
expect(TOUR_AUTOLAUNCH_DELAY_MS).toBe(24 * 60 * 60 * 1000);
});
});
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();
});
});
@@ -5,15 +5,26 @@
* browser) inherited the first user's "tour dismissed for this
* session" flag and the tour silently no-op'd for the second user.
*
* The two key-builders are exported so this lock-test pins the wire
* The key-builders are exported so this lock-test pins the wire
* format. Changing them is a soft compatibility break — every
* already-rendered tab would re-fire the tour for the user once,
* because the un-prefixed legacy keys are never read again. We
* 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.
*/
import { describe, expect, it } from "vitest";
import { tourReferrerKey, tourSessionDismissedKey } from "../tour-launcher";
import {
tourForceLaunchKey,
tourReferrerKey,
tourSessionDismissedKey,
} from "../tour-launcher";
describe("tour-launcher sessionStorage key scoping", () => {
it("scopes the dismiss key by user id", () => {
@@ -26,11 +37,20 @@ 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", () => {
@@ -38,5 +58,20 @@ 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", () => {
// 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.
const userId = "u_test";
const keys = new Set([
tourSessionDismissedKey(userId),
tourReferrerKey(userId),
tourForceLaunchKey(userId),
]);
expect(keys.size).toBe(3);
});
});
+137 -7
View File
@@ -6,7 +6,7 @@
* 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.
* Settings → Account or Settings → About.
*
* Gating rules:
*
@@ -28,13 +28,23 @@
* races with an in-flight `/api/auth/me` refetch, the cached
* response could re-open the tour. The localStorage guard
* blocks that for the rest of the session.
* 5. v1.4.47 W5 — make the tour "the second-visit thing". The
* welcome carousel runs immediately after the wizard; piling
* the tour on top of that consumes ~90 s of forced onboarding
* 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 posts
* `{ completed: false }` to /api/onboarding/tour, then dispatches a
* 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 without reading the flag —
* convenient because invalidating the auth cache + waiting for the
* round-trip would feel laggy.
* 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.
*/
import { useEffect, useState } from "react";
@@ -45,6 +55,51 @@ import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
/**
* v1.4.47 W5 — auto-launch only kicks in 24 h after the wizard
* finished. The first visit is owned by the WelcomeCarousel; the
* tour becomes "the second-visit thing".
*/
export const TOUR_AUTOLAUNCH_DELAY_MS = 24 * 60 * 60 * 1000;
/**
* Pure decision helper exported for unit tests. Given the auth-shape
* timing inputs the launcher cares about, return whether the tour
* should auto-launch right now.
*
* Inputs:
* - `onboardingTourCompleted` — server flag; true means the user
* already finished or dismissed the tour. Hard veto.
* - `onboardingCompletedAt` — ISO timestamp of wizard completion
* (or `null` for users who never finished the wizard). The
* 24 h gate counts from here.
* - `nowMs` — caller-supplied "current time" so tests can pin a
* synthetic clock without monkey-patching `Date.now()`.
*
* Returns:
* - `false` when any veto applies (tour already done; no wizard
* completion yet; wizard finished <24 h ago).
* - `true` when the user is eligible and the 24 h delay has passed.
*
* The function does NOT consult sessionStorage — that's a separate
* concern (per-tab dismiss + post-wizard grace) handled by the
* launcher around it. Pure for testing.
*/
export function shouldAutoLaunchTour(args: {
onboardingTourCompleted: boolean;
onboardingCompletedAt: string | null;
nowMs: number;
}): boolean {
if (args.onboardingTourCompleted) return false;
if (args.onboardingCompletedAt == null) return false;
const completedMs = Date.parse(args.onboardingCompletedAt);
// Date.parse returns NaN for unparseable inputs. Treat the
// 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;
return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS;
}
// v1.4.15 H4 — sessionStorage keys are now scoped by user id so an
// admin impersonating a second user does not inherit the first user's
// "tour dismissed for this session" state. The previous global keys
@@ -64,6 +119,48 @@ 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
@@ -151,6 +248,18 @@ export function TourLauncher({ ready }: TourLauncherProps) {
userId: string;
flag: boolean;
} | null>(null);
// v1.4.47 W5 — clock reading for the 24 h auto-launch gate. The
// useState lazy-initialiser runs once at mount; subsequent re-renders
// reuse the same value. This keeps the render path pure (React's
// purity linter rejects a bare `Date.now()` reference in render) and
// matches the established pattern in `personal-record-badge.tsx`:
// capture the clock at a stable boundary, then derive freshness from
// it. The launcher only consults this value once per (user, flag)
// tuple via the `decidedFor` guard, so a mount that takes ten
// minutes to reach the decision still uses a representative timestamp
// (and a user who crosses the 24 h boundary mid-session will see the
// tour on their next page-load — acceptable for a one-shot welcome).
const [mountedAtMs] = useState(() => Date.now());
const inputsReady = !isLoading && isAuthenticated && !!user && ready;
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
@@ -169,7 +278,28 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// 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.
if (user.onboardingTourCompleted) {
//
// v1.4.47 W5 — the 24 h auto-launch gate. The mount-time clock is
// captured into `mountedAtMs` via a useState lazy initialiser so
// the render-phase decision stays pure (no Date.now() in the
// 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 (!shouldAutoLaunchTour({
onboardingTourCompleted: user.onboardingTourCompleted,
onboardingCompletedAt: user.onboardingCompletedAt,
nowMs: mountedAtMs,
})) {
setShowTour(false);
} else if (readSessionDismissed(user.id)) {
setShowTour(false);
@@ -146,6 +146,29 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.about.");
});
it("<AboutSection> exposes a Replay-tour button for the W5 chain gate", () => {
const html = render(<AboutSection />);
// v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h
// after the wizard finishes (see `shouldAutoLaunchTour` in
// `components/onboarding/tour-launcher.tsx`). A first-day user who
// *does* want the tour needs a discoverable manual trigger, and
// Settings → About is the surface every user can find. Pin both
// the data-testid and the visible copy so a future refactor can't
// silently drop the button.
expect(html).toContain('data-testid="about-replay-tour"');
expect(html).toContain("Replay the tour");
// No raw i18n key leaks past the i18n layer.
expect(html).not.toContain("settings.about.tourReplay");
});
it("<AboutSection> renders the German Replay-tour label end-to-end", () => {
// Marc-voice + i18n parity: the W5 spec explicitly names this
// surface "Tour ansehen" in German. Lock the resolved copy so a
// future locale-bundle drift doesn't silently fall back to English.
const html = render(<AboutSection />, "de");
expect(html).toContain("Tour ansehen");
});
it("<AiSection> renders the Insights card", () => {
const html = render(<AiSection />);
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
+120
View File
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
BookOpen,
Compass,
ExternalLink,
GitBranch,
Info,
@@ -13,6 +14,8 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -116,6 +119,11 @@ export function UpdateBadge({
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(),
@@ -129,6 +137,66 @@ export function AboutSection() {
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);
const [tourMessage, setTourMessage] = useState<string | null>(null);
const [tourMessageType, setTourMessageType] = useState<
"success" | "error" | null
>(null);
async function handleReplayTour() {
setReplayingTour(true);
setTourMessage(null);
setTourMessageType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
setTourMessage(t("settings.savingError"));
setTourMessageType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker into
// sessionStorage so the next dashboard mount opens the tour
// even when the 24 h auto-launch gate would otherwise suppress
// it (e.g. a first-day user who clicked "Replay the tour"
// from About 30 min after finishing the wizard). The marker is
// session-scoped — a page reload clears it — and consumed
// exactly once by the launcher on its next mount.
if (user?.id) setTourForceLaunch(user.id);
// Fire the same window event Settings → Account uses; a
// dashboard already mounted in another tab / background reopens
// the spotlight immediately. When the user lands on the
// dashboard next, the launcher reads the freshly-flipped
// `onboardingTourCompleted = false` from `/api/auth/me` and the
// force-launch marker set above; either path lands on the tour.
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if a dashboard is already mounted */
}
setTourMessage(t("onboarding.tour.restartConfirmation"));
setTourMessageType("success");
} catch {
setTourMessage(t("common.networkError"));
setTourMessageType("error");
} finally {
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
@@ -317,6 +385,58 @@ export function AboutSection() {
</div>
)}
{/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
tour now auto-launches only ≥ 24 h after the wizard finishes,
so a first-day user who wants to see it still needs a
discoverable manual trigger. About is the surface every user
can find (it's the standard "where am I?" stop in the
settings shell), so the button lives here next to version +
links. Mirrors the stack-on-mobile / right-align-on-desktop
contract used by the Account section's "Restart onboarding
tour" card so the two surfaces feel consistent. */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Compass className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.about.tourReplay")}
</h2>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.about.tourReplayHint")}
</p>
</div>
<Button
type="button"
variant="outline"
onClick={handleReplayTour}
disabled={replayingTour}
data-testid="about-replay-tour"
className="w-full shrink-0 sm:w-auto"
>
{replayingTour ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
)}
{t("settings.about.tourReplay")}
</Button>
</div>
{tourMessage && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMessageType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMessage}
</p>
)}
</div>
{/* v1.4.36 W4f — the dedicated "Updates" card with the
manual "Check for updates" button is gone. The 24 h auto-
check still runs on mount and writes its result into the
@@ -39,6 +39,7 @@ import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
@@ -292,6 +293,12 @@ export function AccountSection() {
setTourMsgType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker in
// sessionStorage so the next dashboard mount launches the tour
// even when the new 24 h auto-launch gate would otherwise
// suppress it (e.g. user just finished the wizard 5 min ago).
// The launcher reads-and-clears the marker on mount.
if (user?.id) setTourForceLaunch(user.id);
// Refetch the auth payload so `<TourLauncher>` re-evaluates
// with the new flag, AND fire a window event so a launcher
// already mounted on the dashboard reopens immediately