mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
76e62283fa
commit
5a3b8b142d
12 files changed
+540
-15
No files matched your search
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user