mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(onboarding): make the guided tour first-time-only, drop every re-entry trigger
The guided tour now exists only as the first-time auto-start after onboarding. Remove every in-app trigger that re-opened it: the per-module "show this tour" button on the dashboard, measurements, mood, medications, labs, illness, cycle, achievements, insights, and coach surfaces; and the Settings replay card. Delete the now-orphaned ModuleTourTrigger component, the tour-restart worker, the healthlog:module-tour and healthlog:tour-restart event listeners, the single-stop overlay path, and the force-launch session marker. The first-time auto-launch gate and the tour overlay itself are unchanged. Prune the orphaned i18n keys across all locales and update the affected tests.
This commit is contained in:
1 parent
f67542d29e
commit
3ba542f797
24 files changed
+41
-575
No files matched your search
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { BbtChart } from "./bbt-chart";
|
||||
@@ -151,7 +150,6 @@ export function CycleView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="cycle" />
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { getHourForTimeZone } from "@/components/dashboard/range-display";
|
||||
@@ -97,8 +96,6 @@ export function DashboardHeader({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
|
||||
<ModuleTourTrigger stopId="dashboardOverview" />
|
||||
{/* Customize shortcut to the dashboard-customization settings
|
||||
(the tile/layout editor at /settings/dashboard). Sits to
|
||||
the left of the add button as a monochrome ghost icon, with
|
||||
|
||||
@@ -33,7 +33,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
|
||||
import { LogDaySheet } from "./log-day-sheet";
|
||||
import { NewEpisodeSheet } from "./new-episode-sheet";
|
||||
@@ -380,7 +379,6 @@ export function IllnessView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ModuleTourTrigger stopId="illness" />
|
||||
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
|
||||
Illness settings page (view + reorder). */}
|
||||
<Button
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import {
|
||||
@@ -863,7 +862,6 @@ function InsightsTabStripImpl({
|
||||
{/* v1.18.6 — per-module tour re-entry, travelling with the
|
||||
top-right control cluster. Anchors to this strip's
|
||||
`insights-hero` and opens the Insights tour card on the spot. */}
|
||||
<ModuleTourTrigger stopId="insights" />
|
||||
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
|
||||
regenerate button and links to the Insights settings section
|
||||
(overview arrange + pill sort), matching the Dashboard cog
|
||||
|
||||
@@ -10,20 +10,15 @@
|
||||
* The fix turns the tour into "the second-visit thing": auto-launch
|
||||
* is suppressed until `onboardingCompletedAt + 24 h`. This file pins
|
||||
* the pure decision helper so a future refactor doesn't silently
|
||||
* regress the gate. The "manual restart from Settings" path bypasses
|
||||
* the gate entirely and lives in `<TourLauncher>`'s
|
||||
* `healthlog:tour-restart` window event listener — exercised by the
|
||||
* sections SSR smoke test (`settings/__tests__/sections.test.tsx`) +
|
||||
* the e2e suite, not by this file.
|
||||
* regress the gate. v1.18.6.1 — the tour is first-time-auto-start
|
||||
* only; the former "manual restart from Settings" bypass was removed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
setTourForceLaunch,
|
||||
shouldAutoLaunchTour,
|
||||
tourIncludesAchievements,
|
||||
TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
tourForceLaunchKey,
|
||||
} from "../tour-launcher";
|
||||
|
||||
const NOW = Date.parse("2026-05-21T12:00:00Z");
|
||||
@@ -153,68 +148,3 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => {
|
||||
// (c) of the W5 acceptance: the manual "launch tour" trigger still
|
||||
// works. The launcher's render-phase gate is exercised by
|
||||
// shouldAutoLaunchTour() above; the *bypass* is a sessionStorage
|
||||
// marker the Settings buttons drop and the launcher reads-and-
|
||||
// clears on its next mount. Vitest runs in the Node environment by
|
||||
// default — stub a minimal window so the helper's window-guard +
|
||||
// try/catch wrappers can be exercised end-to-end.
|
||||
let storage: Map<string, string>;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new Map<string, string>();
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the per-user force-launch marker into sessionStorage", () => {
|
||||
// The Settings → About + Settings → Account "Replay tour" buttons
|
||||
// call this exact helper after the API round-trip succeeds. The
|
||||
// launcher reads-and-clears the marker on its next dashboard
|
||||
// mount, bypassing the 24 h auto-launch gate so a same-day user
|
||||
// who explicitly asked for the tour actually gets it.
|
||||
setTourForceLaunch("u_replay_demo");
|
||||
expect(storage.get(tourForceLaunchKey("u_replay_demo"))).toBe("1");
|
||||
});
|
||||
|
||||
it("scopes the marker per user (two users do not bleed into each other)", () => {
|
||||
setTourForceLaunch("u_alice");
|
||||
setTourForceLaunch("u_bob");
|
||||
expect(storage.get(tourForceLaunchKey("u_alice"))).toBe("1");
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
// The two keys are independent slots — clearing one (which the
|
||||
// launcher does on mount) must not affect the other.
|
||||
storage.delete(tourForceLaunchKey("u_alice"));
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
});
|
||||
|
||||
it("does not throw when window is undefined (SSR safety)", () => {
|
||||
// The launcher itself is "use client" but the helper is exported
|
||||
// from the same module and could be imported by a server component
|
||||
// in the future. The window-guard inside the helper has to keep
|
||||
// SSR happy — verify it stays a silent no-op.
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
expect(() => setTourForceLaunch("u_x")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,12 @@
|
||||
* accept that one-off tax in exchange for the multi-tenant
|
||||
* correctness.
|
||||
*
|
||||
* v1.4.47 W5 adds a third key, `tourForceLaunchKey`, that carries
|
||||
* the "user just clicked Replay in Settings → About/Account" intent
|
||||
* across the navigation back to the dashboard. The launcher reads
|
||||
* and clears it on its next mount, bypassing the new 24 h
|
||||
* auto-launch gate so a first-day user who *wants* the tour
|
||||
* actually gets it.
|
||||
* v1.18.6.1 — the tour is first-time-auto-start only; the former
|
||||
* `tourForceLaunchKey` replay-bypass key was removed along with every
|
||||
* in-app tour re-entry trigger.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
tourForceLaunchKey,
|
||||
tourReferrerKey,
|
||||
tourSessionDismissedKey,
|
||||
} from "../tour-launcher";
|
||||
@@ -37,20 +33,11 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
expect(tourReferrerKey("u_abc")).toBe("healthlog-tour-referrer:u_abc");
|
||||
});
|
||||
|
||||
it("scopes the force-launch key by user id (v1.4.47 W5)", () => {
|
||||
expect(tourForceLaunchKey("u_abc")).toBe(
|
||||
"healthlog-tour-force-launch:u_abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns distinct keys for distinct users", () => {
|
||||
expect(tourSessionDismissedKey("u_alice")).not.toBe(
|
||||
tourSessionDismissedKey("u_bob"),
|
||||
);
|
||||
expect(tourReferrerKey("u_alice")).not.toBe(tourReferrerKey("u_bob"));
|
||||
expect(tourForceLaunchKey("u_alice")).not.toBe(
|
||||
tourForceLaunchKey("u_bob"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collide with the legacy un-scoped keys", () => {
|
||||
@@ -58,20 +45,17 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
"healthlog-tour-session-dismissed",
|
||||
);
|
||||
expect(tourReferrerKey("u_x")).not.toBe("healthlog-tour-referrer");
|
||||
expect(tourForceLaunchKey("u_x")).not.toBe("healthlog-tour-force-launch");
|
||||
});
|
||||
|
||||
it("uses three distinct namespaces so the keys can never alias each other", () => {
|
||||
it("uses distinct namespaces so the keys can never alias each other", () => {
|
||||
// Belt-and-braces: if a future refactor accidentally collapses
|
||||
// two key builders onto the same prefix, the launcher's gating
|
||||
// logic would conflate "session-dismissed" with "force-launch"
|
||||
// and produce a one-shot tour-loop on every dashboard mount.
|
||||
// logic would conflate "session-dismissed" with "referrer".
|
||||
const userId = "u_test";
|
||||
const keys = new Set([
|
||||
tourSessionDismissedKey(userId),
|
||||
tourReferrerKey(userId),
|
||||
tourForceLaunchKey(userId),
|
||||
]);
|
||||
expect(keys.size).toBe(3);
|
||||
expect(keys.size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.18.6 — per-module "Diese Tour zeigen" re-entry.
|
||||
*
|
||||
* A small ghost button each module page drops into its header action
|
||||
* cluster. It dispatches the `healthlog:module-tour` window event with
|
||||
* the page's stop id; the shell-level `<TourLauncher>` picks it up and
|
||||
* opens the spotlight narrowed to that single module card on the
|
||||
* current page (no cross-page navigation, no completion flip).
|
||||
*
|
||||
* Anchors to the page's `data-tour-id="<…>-hero"` element via the launcher
|
||||
* → overlay path. The button itself carries no anchor — it only fires
|
||||
* the event.
|
||||
*/
|
||||
|
||||
import { Compass } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TourStopId } from "@/lib/onboarding/tour-state";
|
||||
|
||||
export function ModuleTourTrigger({ stopId }: { stopId: TourStopId }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
function show() {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("healthlog:module-tour", { detail: { stopId } }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={show}
|
||||
// 44px mobile tap floor, matching the rest of the module headers.
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
|
||||
data-testid={`module-tour-trigger-${stopId}`}
|
||||
>
|
||||
<Compass className="h-4 w-4" />
|
||||
<span className="sr-only sm:not-sr-only">
|
||||
{t("onboarding.tour.moduleTrigger")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
*
|
||||
* Decides *when* to start the spotlight tour. Mounted on the
|
||||
* dashboard; opens the tour exactly once for new users and never
|
||||
* again unless they explicitly request a replay from
|
||||
* Settings → Account or Settings → About.
|
||||
* again. v1.18.6.1 — the tour exists ONLY as this first-time
|
||||
* auto-start: every in-app re-entry trigger (the per-module "show
|
||||
* this tour" buttons and the Settings replay card) was removed.
|
||||
*
|
||||
* Gating rules:
|
||||
*
|
||||
@@ -34,17 +35,8 @@
|
||||
* before the user gets to touch the dashboard. The tour now
|
||||
* auto-launches only after the wizard has been finished at
|
||||
* least 24 h ago. Brand-new users (`onboardingCompletedAt ==
|
||||
* null`) never see the auto-launch — they can still trigger
|
||||
* the tour manually from Settings → About or Settings →
|
||||
* Account.
|
||||
*
|
||||
* Replay flow: the Settings → Account "Restart" button (and the
|
||||
* Settings → About "Replay" button added in v1.4.47 W5) POST
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatch a
|
||||
* `healthlog:tour-restart` window event. This launcher listens for
|
||||
* that event and force-opens the tour ignoring the auto-launch gate
|
||||
* (24 h delay, session-dismissed flag, post-wizard grace) so the
|
||||
* manual replay always works.
|
||||
* null`) never see the auto-launch on the same session they
|
||||
* finished onboarding.
|
||||
*/
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -53,7 +45,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import type { TourStopId } from "@/lib/onboarding/tour-state";
|
||||
import { OnboardingTour } from "./tour";
|
||||
|
||||
const POST_WIZARD_GRACE_MS = 1500;
|
||||
@@ -148,48 +139,6 @@ export function tourReferrerKey(userId: string): string {
|
||||
return `healthlog-tour-referrer:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W5 — sessionStorage key used by the Settings → About
|
||||
* "Replay the tour" + Settings → Account "Restart onboarding tour"
|
||||
* buttons to bypass the 24 h auto-launch gate on the next dashboard
|
||||
* mount. Without this, a first-day user who manually clicks the
|
||||
* replay button would still hit the 24 h block after navigating to
|
||||
* the dashboard (because the launcher mounts fresh and re-evaluates
|
||||
* the auto-launch gate). Exported for the unit-test suite + so the
|
||||
* key construction stays pinned across refactors.
|
||||
*/
|
||||
export function tourForceLaunchKey(userId: string): string {
|
||||
return `healthlog-tour-force-launch:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the force-launch marker. Called by both Settings → Account
|
||||
* and Settings → About when the user explicitly requests the tour.
|
||||
* Exported so the Settings buttons can call it without re-implementing
|
||||
* the sessionStorage dance (and so the test suite can stub it).
|
||||
*/
|
||||
export function setTourForceLaunch(userId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(tourForceLaunchKey(userId), "1");
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
function readAndClearForceLaunch(userId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const key = tourForceLaunchKey(userId);
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
if (value !== "1") return false;
|
||||
window.sessionStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user just navigated here from `/onboarding`. Read
|
||||
* once at mount via a `useState` lazy initialiser (which runs in a
|
||||
@@ -253,15 +202,6 @@ function writeSessionDismissed(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionDismissed(userId: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.removeItem(tourSessionDismissedKey(userId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface TourLauncherProps {
|
||||
/**
|
||||
* v1.18.6 — optional readiness sentinel. The dashboard used to pass
|
||||
@@ -287,7 +227,6 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// v1.18.6 — when the tour opens for a single-module re-entry
|
||||
// ("Diese Tour zeigen" on a module page) this carries the stop id so
|
||||
// the overlay renders just that card. `null` = the full tour.
|
||||
const [singleStop, setSingleStop] = useState<TourStopId | null>(null);
|
||||
// Track post-wizard deferral as a boolean so the render path stays
|
||||
// pure (no Date.now() comparisons). The effect below reads / clears it.
|
||||
const [deferredFromWizard, setDeferredFromWizard] = useState(false);
|
||||
@@ -347,17 +286,9 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// render body). The `decidedFor` tuple above further guarantees
|
||||
// this branch runs at most once per (user, flag) transition.
|
||||
//
|
||||
// Force-launch bypass: when the user explicitly requests the tour
|
||||
// from Settings → About / Settings → Account, those buttons set a
|
||||
// per-user sessionStorage marker via `setTourForceLaunch()`. The
|
||||
// launcher reads-and-clears the marker here so the tour launches
|
||||
// on the very next dashboard mount — independent of the 24 h gate,
|
||||
// the session-dismissed flag, and the post-wizard grace. The
|
||||
// marker survives navigation but not a page reload, which matches
|
||||
// user expectation ("I clicked replay, now show me the tour").
|
||||
if (readAndClearForceLaunch(user.id)) {
|
||||
setShowTour(true);
|
||||
} else if (
|
||||
// v1.18.6.1 — the tour is now first-time-auto-start only; there is
|
||||
// no in-app replay trigger and therefore no force-launch bypass.
|
||||
if (
|
||||
!shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
@@ -396,39 +327,10 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [deferredFromWizard, user?.id]);
|
||||
|
||||
// Listen for explicit restart events from Settings → Advanced. A
|
||||
// restart opens the FULL tour from the top (singleStop cleared).
|
||||
// setState inside a window-event listener is fine under the
|
||||
// set-state-in-effect rule.
|
||||
const userIdForRestart = user?.id;
|
||||
useEffect(() => {
|
||||
function onRestart() {
|
||||
if (userIdForRestart) clearSessionDismissed(userIdForRestart);
|
||||
setSingleStop(null);
|
||||
setShowTour(true);
|
||||
}
|
||||
window.addEventListener("healthlog:tour-restart", onRestart);
|
||||
return () => {
|
||||
window.removeEventListener("healthlog:tour-restart", onRestart);
|
||||
};
|
||||
}, [userIdForRestart]);
|
||||
|
||||
// v1.18.6 — per-module "Diese Tour zeigen" re-entry. A module page
|
||||
// dispatches `healthlog:module-tour` with `{ stopId }`; the launcher
|
||||
// opens the overlay narrowed to that single card on the current page
|
||||
// (no cross-page navigation, no completion flip).
|
||||
useEffect(() => {
|
||||
function onModuleTour(e: Event) {
|
||||
const detail = (e as CustomEvent<{ stopId?: string }>).detail;
|
||||
if (!detail?.stopId) return;
|
||||
setSingleStop(detail.stopId as TourStopId);
|
||||
setShowTour(true);
|
||||
}
|
||||
window.addEventListener("healthlog:module-tour", onModuleTour);
|
||||
return () => {
|
||||
window.removeEventListener("healthlog:module-tour", onModuleTour);
|
||||
};
|
||||
}, []);
|
||||
// v1.18.6.1 — the guided tour exists ONLY as the first-time auto-start
|
||||
// after onboarding. The per-module "show this tour" re-entry and the
|
||||
// Settings replay card were removed, so there is no longer any in-app
|
||||
// trigger that re-opens the overlay.
|
||||
|
||||
// v1.18.6 — stable progress callback so the overlay's checkpoint effect
|
||||
// fires on a real step transition, not on every launcher re-render.
|
||||
@@ -446,20 +348,15 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// The resolved module map gates which stops appear (default-on) and
|
||||
// keeps the "Schritt n/total" counter honest.
|
||||
const modules = user?.modules;
|
||||
// v1.18.6 — resume the full tour from the persisted checkpoint. The
|
||||
// single-stop re-entry ignores the resume point (it opens its card).
|
||||
const resumeFromStopId = singleStop
|
||||
? null
|
||||
: (user?.onboardingTourProgress?.lastStopId ?? null);
|
||||
// v1.18.6 — resume the full tour from the persisted checkpoint.
|
||||
const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null;
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
modules={modules}
|
||||
resumeFromStopId={resumeFromStopId}
|
||||
filterToStop={singleStop ?? undefined}
|
||||
// Fire-and-forget checkpoint so a mid-tour reload resumes at the
|
||||
// right module. Skipped implicitly by the overlay for the single-
|
||||
// stop re-entry. A failed write is non-fatal — the resume point is
|
||||
// right module. A failed write is non-fatal — the resume point is
|
||||
// a convenience, not a correctness invariant.
|
||||
onProgress={handleProgress}
|
||||
onClose={async (outcome) => {
|
||||
@@ -470,13 +367,7 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
// for the rest of the session, and the next refetch of
|
||||
// `/api/auth/me` (which the user gets on any nav) will
|
||||
// surface any divergence.
|
||||
const wasSingleStop = singleStop !== null;
|
||||
setShowTour(false);
|
||||
setSingleStop(null);
|
||||
// A single-stop re-entry must NOT flip the global completion
|
||||
// flag or write the session-dismiss guard — it's one card, not
|
||||
// "the tour". Just close.
|
||||
if (wasSingleStop) return;
|
||||
if (user?.id) writeSessionDismissed(user.id);
|
||||
try {
|
||||
await apiPost("/api/onboarding/tour", { completed: true, outcome });
|
||||
|
||||
@@ -136,15 +136,14 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
});
|
||||
|
||||
it("<AdvancedSection> exposes the single Restart-tour card", () => {
|
||||
// v1.18.1 (D1) — the tour-replay card moved from Account into Advanced.
|
||||
// v1.18.6 — relabelled "Restart module tour" and consolidated as the one
|
||||
// full-tour replay home (the About duplicate was removed).
|
||||
it("<AdvancedSection> no longer carries a tour-replay card", () => {
|
||||
// v1.18.6.1 — the guided tour exists only as the first-time auto-start
|
||||
// after onboarding. Every in-app replay / re-entry trigger was removed,
|
||||
// including this Advanced card.
|
||||
const html = render(<AdvancedSection />);
|
||||
expect(html).toContain('data-testid="settings-restart-tour"');
|
||||
expect(html).toContain("Restart module tour");
|
||||
// No raw i18n key leaks.
|
||||
expect(html).not.toContain("onboarding.tour.");
|
||||
expect(html).not.toContain('data-testid="settings-restart-tour"');
|
||||
expect(html).not.toContain("Restart module tour");
|
||||
expect(html).not.toContain("onboarding.tour.restart");
|
||||
});
|
||||
|
||||
it("<AccountSection> no longer carries the Restart-tour button", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpenCheck, Compass, Loader2, Trash2 } from "lucide-react";
|
||||
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,9 +20,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
type ResearchModeStatus,
|
||||
@@ -53,10 +51,11 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
export function AdvancedSection() {
|
||||
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
||||
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
|
||||
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
|
||||
// only as the first-time auto-start after onboarding. This page is the
|
||||
// research-mode + data/account destructive controls.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
|
||||
<TourReplayCard />
|
||||
<ResearchModeCard />
|
||||
<DataResetCard />
|
||||
<AccountDeleteCard />
|
||||
@@ -64,85 +63,6 @@ export function AdvancedSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 (D1) — onboarding-tour replay, relocated from Settings → Account.
|
||||
*
|
||||
* Resets `users.onboarding_tour_completed` on the server and dispatches a
|
||||
* `healthlog:tour-restart` window event so a dashboard already in the
|
||||
* background reopens the spotlight tour immediately. Delegates to the shared
|
||||
* `restartOnboardingTour()` worker (the same one Settings → About uses) and
|
||||
* refetches the auth payload so the launcher's `onboardingTourCompleted` flag
|
||||
* matches the server flip without a navigation.
|
||||
*/
|
||||
function TourReplayCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
key: string;
|
||||
type: "success" | "error";
|
||||
} | null>(null);
|
||||
|
||||
async function handleRestartTour() {
|
||||
setRestarting(true);
|
||||
setFeedback(null);
|
||||
const result = await restartOnboardingTour(user?.id);
|
||||
if (result.ok) {
|
||||
await refetch();
|
||||
setFeedback({
|
||||
key: "onboarding.tour.restartConfirmation",
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
setFeedback({ key: result.messageKey, type: "error" });
|
||||
}
|
||||
setRestarting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Compass className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.tour.restart")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.tour.restartHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRestartTour}
|
||||
disabled={restarting}
|
||||
data-testid="settings-restart-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="h-4 w-4" />
|
||||
)}
|
||||
{t("onboarding.tour.restart")}
|
||||
</Button>
|
||||
</div>
|
||||
{feedback && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-xs ${
|
||||
feedback.type === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{t(feedback.key)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
|
||||
* the toggle's caption out of the JSX. The four-way ladder (loading /
|
||||
|
||||
Reference in new issue
Block a user