mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(onboarding): tour component with spotlight + keyboard navigation
v1.4.15 Phase B5 — second commit. Adds the spotlight-tour overlay
that walks new users through 5 dashboard features after they finish
the wizard at /onboarding:
1. Tile strip (BP / weight / pulse / mood)
2. Quick-add menu
3. Insights (AI-assisted summaries)
4. Settings → Integrations
5. Achievements (omittable via prop if B4 hasn't shipped)
Architecture:
- `src/lib/onboarding/tour-state.ts` — pure state machine. Steps,
navigation helpers (`nextStep`, `prevStep`, `skipTour`),
completion outcome (`"completed" | "skipped"`). DOM-free so the
Node-environment vitest setup can exercise it directly. 13 unit
tests cover step list, navigation, idempotency once finished,
and the 1-based step counter.
- `src/components/onboarding/tour.tsx` — UI layer. In-house
spotlight (no new dependency — package.json had no
react-joyride / intro.js / shepherd already, and the surface
area didn't justify a 30 KB add). Renders a fixed full-viewport
overlay with a polygon `clip-path` cutout aligned to the live
target, plus a tooltip card next to it. Targets are looked up
via the stable `data-tour-id` attribute (NOT class names — the
contract has to survive Tailwind churn). Re-measures on resize
and scroll. Falls back to a centred-screen tooltip if the
target is not in the DOM (mobile breakpoint, conditional
render) so the tour never blocks indefinitely.
- i18n: 19 new keys under `onboarding.tour.*` (EN + DE), one
title + body per stop plus `next`/`back`/`skip`/`done`/
`restart`/`stepOf` strings. The component test asserts the
German strings resolve end-to-end via `<I18nProvider initialLocale="de">`.
Accessibility:
- `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,
polite live region announces step transitions, focus moves to
the primary action on every step.
- Keyboard: Esc = skip, ArrowRight / Enter = next, ArrowLeft =
back. Inputs / textareas / contenteditable nodes are
explicitly NOT hijacked (defensive against future tour content
that might embed an input).
- `prefers-reduced-motion` disables the backdrop fade.
Tests: 917 / 917 unit pass (was 890 — +27 across 2 new files).
Lint clean (13 pre-existing warnings unchanged). Typecheck clean
for B5 files; dashboard-layout / sibling-agent typecheck issues
are pre-existing and out of scope.
The component is "dumb" — it takes `onClose(outcome)` and lets the
caller persist the flag. The launcher wiring lands in the next
commit (B5-3) so this commit is small and orthogonal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
34c967c14a
commit
e57fc0a8da
6 files changed
+926
-2
No files matched your search
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildTourStops,
|
||||
currentStop,
|
||||
initTourState,
|
||||
isTourFinished,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour,
|
||||
stepCounter,
|
||||
} from "../tour-state";
|
||||
|
||||
describe("tour-state", () => {
|
||||
describe("buildTourStops()", () => {
|
||||
it("includes the achievements stop by default (5 stops)", () => {
|
||||
const stops = buildTourStops();
|
||||
expect(stops.map((s) => s.id)).toEqual([
|
||||
"tile-strip",
|
||||
"quick-add",
|
||||
"insights",
|
||||
"integrations",
|
||||
"achievements",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the achievements stop when the flag is off (4 stops)", () => {
|
||||
const stops = buildTourStops({ includeAchievements: false });
|
||||
expect(stops.map((s) => s.id)).toEqual([
|
||||
"tile-strip",
|
||||
"quick-add",
|
||||
"insights",
|
||||
"integrations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every stop carries a non-empty i18n title and body key", () => {
|
||||
const stops = buildTourStops();
|
||||
for (const s of stops) {
|
||||
expect(s.titleKey).toMatch(/^onboarding\.tour\.steps\./);
|
||||
expect(s.bodyKey).toMatch(/^onboarding\.tour\.steps\./);
|
||||
expect(s.titleKey).not.toBe(s.bodyKey);
|
||||
}
|
||||
});
|
||||
|
||||
it("every stop declares a stable targetId for the data-tour-id contract", () => {
|
||||
const stops = buildTourStops();
|
||||
for (const s of stops) {
|
||||
// `null` is allowed (centred-screen tooltip without a
|
||||
// spotlight cutout) but we currently use real targets for
|
||||
// every stop — a regression here would mean the spotlight
|
||||
// silently falls back to centre-screen.
|
||||
expect(typeof s.targetId === "string" && s.targetId.length > 0).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
it("starts at index 0 with outcome null", () => {
|
||||
const state = initTourState(buildTourStops());
|
||||
expect(state.index).toBe(0);
|
||||
expect(state.outcome).toBeNull();
|
||||
expect(isTourFinished(state)).toBe(false);
|
||||
expect(currentStop(state)?.id).toBe("tile-strip");
|
||||
});
|
||||
|
||||
it("nextStep advances through the list", () => {
|
||||
let state = initTourState(buildTourStops());
|
||||
state = nextStep(state);
|
||||
expect(currentStop(state)?.id).toBe("quick-add");
|
||||
state = nextStep(state);
|
||||
expect(currentStop(state)?.id).toBe("insights");
|
||||
});
|
||||
|
||||
it("nextStep on the last step finishes the tour with outcome=completed", () => {
|
||||
const stops = buildTourStops();
|
||||
let state = initTourState(stops);
|
||||
// step through every stop
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
state = nextStep(state);
|
||||
}
|
||||
expect(state.outcome).toBe("completed");
|
||||
expect(isTourFinished(state)).toBe(true);
|
||||
expect(currentStop(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("prevStep is pinned at index 0 (LeftArrow on first step is a no-op)", () => {
|
||||
const state = initTourState(buildTourStops());
|
||||
const back = prevStep(state);
|
||||
expect(back.index).toBe(0);
|
||||
expect(back.outcome).toBeNull();
|
||||
});
|
||||
|
||||
it("prevStep walks backward when not at the start", () => {
|
||||
let state = initTourState(buildTourStops());
|
||||
state = nextStep(state);
|
||||
state = nextStep(state);
|
||||
expect(state.index).toBe(2);
|
||||
const back = prevStep(state);
|
||||
expect(back.index).toBe(1);
|
||||
});
|
||||
|
||||
it("skipTour from any step finishes with outcome=skipped", () => {
|
||||
let state = initTourState(buildTourStops());
|
||||
state = nextStep(state); // on step 2 of 5
|
||||
const skipped = skipTour(state);
|
||||
expect(skipped.outcome).toBe("skipped");
|
||||
expect(isTourFinished(skipped)).toBe(true);
|
||||
expect(currentStop(skipped)).toBeNull();
|
||||
});
|
||||
|
||||
it("nextStep / prevStep / skipTour are no-ops once finished", () => {
|
||||
let state = initTourState(buildTourStops());
|
||||
state = skipTour(state);
|
||||
const after = nextStep(prevStep(skipTour(state)));
|
||||
expect(after).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepCounter", () => {
|
||||
it("reports 1-based current and the total step count", () => {
|
||||
const stops = buildTourStops();
|
||||
let state = initTourState(stops);
|
||||
expect(stepCounter(state)).toEqual({ current: 1, total: stops.length });
|
||||
state = nextStep(state);
|
||||
expect(stepCounter(state)).toEqual({ current: 2, total: stops.length });
|
||||
});
|
||||
|
||||
it("clamps `current` to total when the tour is finished", () => {
|
||||
let state = initTourState(buildTourStops({ includeAchievements: false }));
|
||||
state = skipTour(state);
|
||||
expect(stepCounter(state)).toEqual({ current: 4, total: 4 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* v1.4.15 Phase B5 — onboarding tour state model.
|
||||
*
|
||||
* Pure data + state-machine helpers for the spotlight tour overlaid on
|
||||
* the dashboard for first-time users. Kept DOM-free so vitest (Node
|
||||
* environment, no jsdom) can exercise the navigation logic directly,
|
||||
* matching the project test-style elsewhere.
|
||||
*
|
||||
* The UI layer (`src/components/onboarding/tour.tsx`) builds the
|
||||
* step list once on mount and then drives navigation through these
|
||||
* helpers.
|
||||
*
|
||||
* Tour stops are anchored to UI targets via `data-tour-id` attributes
|
||||
* (NOT class-name selectors — class names change too often during
|
||||
* Tailwind tweaks; `data-tour-id` is a stable contract). Every stop
|
||||
* declares its `targetId`; the spotlight component looks up the live
|
||||
* element via `document.querySelector('[data-tour-id="<id>"]')`.
|
||||
*
|
||||
* If a target element is missing at runtime (e.g. user is on a
|
||||
* resized layout that hides it), the spotlight falls back to a
|
||||
* centred-screen tooltip so the tour never blocks indefinitely.
|
||||
*/
|
||||
|
||||
/** Stable IDs for every stop in the tour. */
|
||||
export type TourStopId =
|
||||
| "tile-strip"
|
||||
| "quick-add"
|
||||
| "insights"
|
||||
| "integrations"
|
||||
| "achievements";
|
||||
|
||||
/**
|
||||
* One step in the tour. The `targetId` matches a `data-tour-id` on a
|
||||
* live element somewhere in the app; the title/body i18n keys live
|
||||
* under `onboarding.tour.steps.<id>.*`.
|
||||
*/
|
||||
export interface TourStop {
|
||||
id: TourStopId;
|
||||
/**
|
||||
* The `data-tour-id` value of the element to anchor the spotlight
|
||||
* to. May be `null` if the stop is purely informational and should
|
||||
* render in a centred-screen tooltip without a spotlight cutout.
|
||||
*/
|
||||
targetId: string | null;
|
||||
/**
|
||||
* Preferred placement of the tooltip relative to the target. The UI
|
||||
* layer is allowed to flip this if the target is too close to a
|
||||
* viewport edge.
|
||||
*/
|
||||
placement: "top" | "bottom" | "left" | "right" | "center";
|
||||
/** i18n key for the step title. */
|
||||
titleKey: string;
|
||||
/** i18n key for the step body. */
|
||||
bodyKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical tour-step list. Caller may inject a feature
|
||||
* flag — e.g. `includeAchievements: false` if B4 hasn't shipped yet
|
||||
* — and we omit that stop without renumbering.
|
||||
*/
|
||||
export function buildTourStops(opts?: {
|
||||
includeAchievements?: boolean;
|
||||
}): TourStop[] {
|
||||
const includeAchievements = opts?.includeAchievements ?? true;
|
||||
|
||||
const stops: TourStop[] = [
|
||||
{
|
||||
id: "tile-strip",
|
||||
targetId: "dashboard-tile-strip",
|
||||
placement: "bottom",
|
||||
titleKey: "onboarding.tour.steps.tileStrip.title",
|
||||
bodyKey: "onboarding.tour.steps.tileStrip.body",
|
||||
},
|
||||
{
|
||||
id: "quick-add",
|
||||
targetId: "dashboard-quick-add",
|
||||
placement: "bottom",
|
||||
titleKey: "onboarding.tour.steps.quickAdd.title",
|
||||
bodyKey: "onboarding.tour.steps.quickAdd.body",
|
||||
},
|
||||
{
|
||||
id: "insights",
|
||||
targetId: "nav-insights",
|
||||
placement: "right",
|
||||
titleKey: "onboarding.tour.steps.insights.title",
|
||||
bodyKey: "onboarding.tour.steps.insights.body",
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
// The integrations link sits behind the Settings sub-menu on
|
||||
// some viewports — we anchor to the Settings nav item instead
|
||||
// so the spotlight is always visible without forcing a hover.
|
||||
targetId: "nav-settings",
|
||||
placement: "right",
|
||||
titleKey: "onboarding.tour.steps.integrations.title",
|
||||
bodyKey: "onboarding.tour.steps.integrations.body",
|
||||
},
|
||||
];
|
||||
|
||||
if (includeAchievements) {
|
||||
stops.push({
|
||||
id: "achievements",
|
||||
targetId: "nav-achievements",
|
||||
placement: "right",
|
||||
titleKey: "onboarding.tour.steps.achievements.title",
|
||||
bodyKey: "onboarding.tour.steps.achievements.body",
|
||||
});
|
||||
}
|
||||
|
||||
return stops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tour navigation state. Pure value, immutable per transition — every
|
||||
* helper returns a fresh state instead of mutating in place.
|
||||
*/
|
||||
export interface TourState {
|
||||
/** Index into the steps array. May equal `steps.length` to indicate the tour is complete. */
|
||||
index: number;
|
||||
/** Cached step list — handy for the renderer. */
|
||||
steps: TourStop[];
|
||||
/**
|
||||
* `null` while the tour is still running, `"completed"` once the
|
||||
* user reached the end and clicked Done, `"skipped"` if they hit
|
||||
* Skip on any step. Both terminal states cause the same DB write
|
||||
* (`onboardingTourCompleted = true`); we keep them distinct purely
|
||||
* so analytics can tell them apart in audit logs.
|
||||
*/
|
||||
outcome: "completed" | "skipped" | null;
|
||||
}
|
||||
|
||||
/** Initial state at the first step. */
|
||||
export function initTourState(steps: TourStop[]): TourState {
|
||||
return { index: 0, steps, outcome: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next step. If we're already on the last step, mark
|
||||
* the tour `completed`. Calling on a terminal state is a no-op so
|
||||
* the renderer can fire-and-forget on rapid double-clicks.
|
||||
*/
|
||||
export function nextStep(state: TourState): TourState {
|
||||
if (state.outcome !== null) return state;
|
||||
const lastIndex = state.steps.length - 1;
|
||||
if (state.index >= lastIndex) {
|
||||
return { ...state, index: state.steps.length, outcome: "completed" };
|
||||
}
|
||||
return { ...state, index: state.index + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step backwards. Pinned at index 0 — the first step has no Back
|
||||
* button visually; this helper just no-ops to keep the keyboard
|
||||
* shortcut (LeftArrow) safe.
|
||||
*/
|
||||
export function prevStep(state: TourState): TourState {
|
||||
if (state.outcome !== null) return state;
|
||||
if (state.index <= 0) return state;
|
||||
return { ...state, index: state.index - 1 };
|
||||
}
|
||||
|
||||
/** User explicitly dismissed the tour from any step. */
|
||||
export function skipTour(state: TourState): TourState {
|
||||
if (state.outcome !== null) return state;
|
||||
return { ...state, index: state.steps.length, outcome: "skipped" };
|
||||
}
|
||||
|
||||
/** Whether the tour has reached a terminal state. */
|
||||
export function isTourFinished(state: TourState): boolean {
|
||||
return state.outcome !== null;
|
||||
}
|
||||
|
||||
/** The current stop, or null if the tour is finished. */
|
||||
export function currentStop(state: TourState): TourStop | null {
|
||||
if (state.outcome !== null) return null;
|
||||
return state.steps[state.index] ?? null;
|
||||
}
|
||||
|
||||
/** 1-based step counter for "{n}/{total}" displays. */
|
||||
export function stepCounter(state: TourState): {
|
||||
current: number;
|
||||
total: number;
|
||||
} {
|
||||
return {
|
||||
current: Math.min(state.index + 1, state.steps.length),
|
||||
total: state.steps.length,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user