diff --git a/messages/de.json b/messages/de.json index 00b4e95d0..5fac77931 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 00a064ed4..f8ca42f8e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 2cd430fbe..2ce6a1d5a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 2ad38aa29..b2e101020 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 26b6a437a..4757bf1f1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 739c9d2e5..4cd512dba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts new file mode 100644 index 000000000..b282a4e40 --- /dev/null +++ b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts @@ -0,0 +1,198 @@ +/** + * v1.4.47 W5 — onboarding chain gate. + * + * `` (gated on `users.onboardingCompletedAt`) and + * `` (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 ``'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; + 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 eaa1509c0..cc51c0dae 100644 --- a/src/components/onboarding/__tests__/tour-launcher-keys.test.ts +++ b/src/components/onboarding/__tests__/tour-launcher-keys.test.ts @@ -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); }); }); diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 2d2932775..f1fc4b694 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -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); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 9c2bfc534..88300feb2 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -146,6 +146,29 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.about."); }); + it(" exposes a Replay-tour button for the W5 chain gate", () => { + const html = render(); + // v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h + // after the wizard finishes (see `shouldAutoLaunchTour` in + // `components/onboarding/tour-launcher.tsx`). A first-day user who + // *does* want the tour needs a discoverable manual trigger, and + // Settings → About is the surface every user can find. Pin both + // the data-testid and the visible copy so a future refactor can't + // silently drop the button. + expect(html).toContain('data-testid="about-replay-tour"'); + expect(html).toContain("Replay the tour"); + // No raw i18n key leaks past the i18n layer. + expect(html).not.toContain("settings.about.tourReplay"); + }); + + it(" renders the German Replay-tour label end-to-end", () => { + // 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(, "de"); + expect(html).toContain("Tour ansehen"); + }); + it(" renders the Insights card", () => { const html = render(); // v1.4.33 IW7 — section renamed from "AI Insights" to "Insights" diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 16d1a1810..8565d8532 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -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(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() { )} + {/* 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. */} +
+
+
+
+ +

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

+
+

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

+
+ +
+ {tourMessage && ( +

+ {tourMessage} +

+ )} +
+ {/* 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 diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index ba846da25..c9b9b4e60 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -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 `` re-evaluates // with the new flag, AND fire a window event so a launcher // already mounted on the dashboard reopens immediately