mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(onboarding): keep the tour from bouncing between the Coach and Insights
With no provider set up, a fresh self-host's default, the Coach module is on but /coach redirects to /insights because the Coach is not available. The tour read the raw module map, kept its Coach stop, pushed /coach, was sent back to /insights and pushed /coach again on the pathname change: a loop. The tour now reads the same capability-folded map as the navigation, so the Coach stop is not in the list. And a stop whose page redirects is dropped after one navigation, in the direction the person was going, instead of being pushed again.
This commit is contained in:
1 parent
676bb2eff7
commit
28d491174e
5 files changed
+231
-5
No files matched your search
@@ -13,6 +13,8 @@
|
||||
* 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 { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -147,3 +149,23 @@ describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => {
|
||||
expect(tourIncludesAchievements({ sleep: false, mood: false })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the launcher hands the tour the capability-folded module map", () => {
|
||||
// The raw `user.modules` says the Coach module is on for a fresh self-host
|
||||
// with no provider, while `/coach` redirects to `/insights` because the
|
||||
// Coach capability is unavailable: the tour pushed `/coach`, was sent back,
|
||||
// and pushed again. The launcher must read `useNavModules()`, which folds
|
||||
// the capability into `coach`, exactly as the nav does.
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/onboarding/tour-launcher.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("reads useNavModules()", () => {
|
||||
expect(source).toMatch(/\buseNavModules\(\)/);
|
||||
});
|
||||
|
||||
it("does not pass the raw account module map to the tour", () => {
|
||||
expect(source).not.toMatch(/modules\s*=\s*user\?\.modules/);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { OnboardingTour } from "./tour";
|
||||
@@ -217,6 +218,7 @@ interface TourLauncherProps {
|
||||
|
||||
export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const queryClient = useQueryClient();
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -350,9 +352,12 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
|
||||
|
||||
if (showTour !== true) return null;
|
||||
|
||||
// The resolved module map gates which stops appear (default-on) and
|
||||
// keeps the "Schritt n/total" counter honest.
|
||||
const modules = user?.modules;
|
||||
// The module map the nav reads gates which stops appear (default-on) and
|
||||
// keeps the "Schritt n/total" counter honest. It folds the Coach
|
||||
// capability in: with no provider or no consent the Coach module is on
|
||||
// but `/coach` redirects, so the raw `user.modules` would keep a stop the
|
||||
// tour can never stand on.
|
||||
const modules = navModules;
|
||||
// v1.18.6 — resume the full tour from the persisted checkpoint.
|
||||
const resumeFromStopId = user?.onboardingTourProgress?.lastStopId ?? null;
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
prevStep,
|
||||
skipTour,
|
||||
stepCounter,
|
||||
dropRedirectedStop,
|
||||
stopRouteRedirected,
|
||||
type TourModuleMap,
|
||||
type TourState,
|
||||
type TourStop,
|
||||
@@ -257,6 +259,21 @@ export function OnboardingTour({
|
||||
initTourState(stops, filterToStop ? null : resumeFromStopId),
|
||||
);
|
||||
const [rect, setRect] = useState<SpotlightRect | null>(null);
|
||||
// Which stop the tour last navigated for, from where, and whether that
|
||||
// stop's page was reached; and which way the person was travelling, so a
|
||||
// stop whose page redirects is stepped past in the same direction.
|
||||
const navRef = useRef<{
|
||||
stopId: string | null;
|
||||
pushedFrom: string | null;
|
||||
arrived: boolean;
|
||||
}>({ stopId: null, pushedFrom: null, arrived: false });
|
||||
const directionRef = useRef<"forward" | "back">("forward");
|
||||
// The navigation effect reads the latest `onClose` without re-running on
|
||||
// its identity (the launcher passes a fresh closure every render).
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
const stop = currentStop(state);
|
||||
|
||||
@@ -285,8 +302,42 @@ export function OnboardingTour({
|
||||
// stops (the two dashboard stops while on `/`, or the wrap-up which has
|
||||
// no route) skip the push. `filterToStop` re-entry never navigates — it
|
||||
// runs on the page the user is already viewing.
|
||||
if (!filterToStop && stop.route && stop.route !== pathname) {
|
||||
router.push(stop.route);
|
||||
//
|
||||
// One push per stop. A page that sends the person elsewhere (the Coach
|
||||
// page redirects to `/insights` while the Coach is unavailable) would
|
||||
// otherwise be pushed again on every pathname change, a loop between the
|
||||
// two pages; such a stop is dropped and the tour moves on.
|
||||
if (!filterToStop && stop.route) {
|
||||
if (navRef.current.stopId !== stop.id) {
|
||||
navRef.current = { stopId: stop.id, pushedFrom: null, arrived: false };
|
||||
}
|
||||
const nav = navRef.current;
|
||||
if (pathname === stop.route) {
|
||||
nav.arrived = true;
|
||||
} else if (
|
||||
stopRouteRedirected({
|
||||
stopRoute: stop.route,
|
||||
pathname,
|
||||
pushedFrom: nav.pushedFrom,
|
||||
arrived: nav.arrived,
|
||||
})
|
||||
) {
|
||||
const direction = directionRef.current;
|
||||
queueMicrotask(() =>
|
||||
setState((prev) => {
|
||||
if (currentStop(prev)?.id !== stop.id) return prev;
|
||||
const next = dropRedirectedStop(prev, direction);
|
||||
if (isTourFinished(next)) {
|
||||
queueMicrotask(() => onCloseRef.current("completed"));
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
} else if (nav.pushedFrom === null) {
|
||||
nav.pushedFrom = pathname;
|
||||
router.push(stop.route);
|
||||
}
|
||||
}
|
||||
|
||||
const measureNow = () => {
|
||||
@@ -397,6 +448,7 @@ export function OnboardingTour({
|
||||
}, [onClose]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
directionRef.current = "forward";
|
||||
setState((prev) => {
|
||||
const next = nextStep(prev);
|
||||
if (isTourFinished(next) && next.outcome === "completed") {
|
||||
@@ -407,6 +459,7 @@ export function OnboardingTour({
|
||||
}, [onClose]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
directionRef.current = "back";
|
||||
setState((prev) => prevStep(prev));
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in new issue
Block a user