diff --git a/messages/de.json b/messages/de.json
index 7065db535..aa52c49aa 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -120,7 +120,24 @@
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "im Ziel",
"glucoseBelowTarget": "unter Ziel",
- "glucoseAboveTarget": "über Ziel"
+ "glucoseAboveTarget": "über Ziel",
+ "practiceLabel": "Praxis",
+ "dialog": {
+ "title": "Arztbericht (PDF)",
+ "description": "Lege den Auswertungszeitraum fest und (optional) den Praxisnamen — beide erscheinen auf dem Deckblatt.",
+ "startLabel": "Von",
+ "endLabel": "Bis",
+ "practiceLabel": "Praxis / Klinik (optional)",
+ "practicePlaceholder": "z. B. Dr. Müller — Hausarztpraxis",
+ "preset90d": "Letzte 90 Tage",
+ "preset180d": "Letzte 6 Monate",
+ "preset365d": "Letzte 12 Monate",
+ "submit": "PDF erstellen",
+ "cancel": "Abbrechen",
+ "errorEndBeforeStart": "Das Ende-Datum darf nicht vor dem Start liegen.",
+ "errorRangeTooLong": "Der Auswertungszeitraum darf maximal 2 Jahre umfassen.",
+ "errorInvalidDate": "Bitte wähle ein gültiges Start- und Ende-Datum."
+ }
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -846,6 +863,38 @@
"openSettings": "In den Einstellungen fortsetzen"
},
"doneToast": "Alles eingerichtet — willkommen an Bord."
+ },
+ "tour": {
+ "stepOf": "Schritt {current} von {total}",
+ "next": "Weiter",
+ "back": "Zurück",
+ "skip": "Tour überspringen",
+ "done": "Verstanden",
+ "restart": "Tour neu starten",
+ "restartHint": "Zeige die Dashboard-Tour beim nächsten Aufruf erneut.",
+ "restartConfirmation": "Die Tour startet bei deinem nächsten Dashboard-Besuch erneut.",
+ "steps": {
+ "tileStrip": {
+ "title": "Deine Tageswerte",
+ "body": "Die Kachel-Leiste zeigt den letzten Wert und den 7-Tage-Trend für jede Metrik — Gewicht, Blutdruck, Puls, Stimmung. Was du nicht brauchst, blendest du in Einstellungen → Dashboard aus."
+ },
+ "quickAdd": {
+ "title": "In zwei Taps erfasst",
+ "body": "Über den Hinzufügen-Button kannst du Messungen oder Stimmungseinträge anlegen, ohne das Dashboard zu verlassen. Das Formular merkt sich deinen letzten Kontext und Tags."
+ },
+ "insights": {
+ "title": "KI-gestützte Einblicke",
+ "body": "Insights verwandelt deine Werte in eine klare Gesundheits-Zusammenfassung, mit Einordnung von Blutdruck, Gewicht und Stimmung. Eigener KI-Key oder Standard — du entscheidest."
+ },
+ "integrations": {
+ "title": "Geräte verbinden",
+ "body": "In den Einstellungen verbindest du Withings (Auto-Sync für Gewicht, Blutdruck, Puls), moodLog oder deinen KI-Provider. Alles bleibt optional und verschlüsselt."
+ },
+ "achievements": {
+ "title": "Streaks und Badges sammeln",
+ "body": "Die Achievements-Seite trackt tägliche Logging-Streaks, Blutdruck-Meilensteine und Konsistenz-Abzeichen. Ein kleiner Anreiz, dranzubleiben."
+ }
+ }
}
},
"gettingStarted": {
diff --git a/messages/en.json b/messages/en.json
index 6a2fcced2..9e6268cae 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -120,7 +120,24 @@
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "in target",
"glucoseBelowTarget": "below target",
- "glucoseAboveTarget": "above target"
+ "glucoseAboveTarget": "above target",
+ "practiceLabel": "Practice",
+ "dialog": {
+ "title": "Doctor report (PDF)",
+ "description": "Pick the reporting period and (optionally) the practice name; both are printed on the cover.",
+ "startLabel": "Start date",
+ "endLabel": "End date",
+ "practiceLabel": "Practice / clinic name (optional)",
+ "practicePlaceholder": "e.g. Dr. Müller — Family practice",
+ "preset90d": "Last 90 days",
+ "preset180d": "Last 6 months",
+ "preset365d": "Last 12 months",
+ "submit": "Generate PDF",
+ "cancel": "Cancel",
+ "errorEndBeforeStart": "End date must be on or after the start date.",
+ "errorRangeTooLong": "The reporting period cannot exceed 2 years.",
+ "errorInvalidDate": "Please select a valid start and end date."
+ }
},
"errorBoundary": {
"title": "Something went wrong",
@@ -846,6 +863,38 @@
"openSettings": "Continue in Settings"
},
"doneToast": "You're all set — welcome aboard."
+ },
+ "tour": {
+ "stepOf": "Step {current} of {total}",
+ "next": "Next",
+ "back": "Back",
+ "skip": "Skip tour",
+ "done": "Got it",
+ "restart": "Restart onboarding tour",
+ "restartHint": "Replay the dashboard walkthrough from the start.",
+ "restartConfirmation": "Tour will restart on your next dashboard visit.",
+ "steps": {
+ "tileStrip": {
+ "title": "Your daily numbers",
+ "body": "The tile strip shows the latest reading and a 7-day trend for every metric you log — weight, blood pressure, pulse, mood. Hide what you don't need from Settings → Dashboard."
+ },
+ "quickAdd": {
+ "title": "Log anything in two taps",
+ "body": "Use the Add button to drop a measurement or mood entry without leaving the dashboard. The form remembers your last context and tags."
+ },
+ "insights": {
+ "title": "AI-assisted insights",
+ "body": "Insights turns your readings into a plain-English health summary, with classifications for blood pressure, weight trend and mood. Bring your own AI key or use the built-in default."
+ },
+ "integrations": {
+ "title": "Connect your devices",
+ "body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your AI provider. Everything stays optional and encrypted."
+ },
+ "achievements": {
+ "title": "Earn streaks and badges",
+ "body": "The Achievements page tracks daily logging streaks, blood-pressure milestones and consistency badges. A small nudge to keep going."
+ }
+ }
}
},
"gettingStarted": {
diff --git a/src/components/onboarding/__tests__/tour.test.tsx b/src/components/onboarding/__tests__/tour.test.tsx
new file mode 100644
index 000000000..4bf56bb11
--- /dev/null
+++ b/src/components/onboarding/__tests__/tour.test.tsx
@@ -0,0 +1,82 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+// vitest runs in the Node environment (no jsdom). The tour's interactive
+// behaviour (keyboard navigation, focus management, target measurement)
+// is exercised by the pure state machine at
+// `src/lib/onboarding/__tests__/tour-state.test.ts` and by the e2e
+// suite at `e2e/onboarding-tour.spec.ts`. Here we lock the SSR contract:
+// initial render shape, ARIA wiring, i18n keys, and that the first
+// step is the tile strip.
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { OnboardingTour } from "../tour";
+
+function renderTour(includeAchievements = true) {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe("", () => {
+ it("renders a modal dialog with role=dialog and aria-modal=true", () => {
+ const html = renderTour();
+ expect(html).toContain('role="dialog"');
+ expect(html).toContain('aria-modal="true"');
+ expect(html).toContain('aria-labelledby="onboarding-tour-title"');
+ });
+
+ it("starts on the tile-strip step (1 of 5 by default)", () => {
+ const html = renderTour();
+ expect(html).toContain("Step 1 of 5");
+ expect(html).toContain("Your daily numbers");
+ // Body of the first step should mention the tile strip context.
+ expect(html).toContain("tile strip");
+ });
+
+ it("drops the achievements stop when the flag is off (1 of 4)", () => {
+ const html = renderTour(false);
+ expect(html).toContain("Step 1 of 4");
+ });
+
+ it("exposes a skip control with an accessible label", () => {
+ const html = renderTour();
+ // The backdrop button doubles as a Skip control with aria-label,
+ // and the explicit footer Skip button.
+ expect(html).toContain("Skip tour");
+ });
+
+ it("renders both the Skip button and the primary Next button on the first step", () => {
+ const html = renderTour();
+ expect(html).toContain("Skip tour");
+ expect(html).toContain(">Next<");
+ });
+
+ it("attaches a stable test hook for the primary action so e2e can drive it", () => {
+ const html = renderTour();
+ expect(html).toContain('data-testid="onboarding-tour-primary"');
+ expect(html).toContain('data-testid="onboarding-tour-tooltip"');
+ expect(html).toContain('data-testid="onboarding-tour"');
+ });
+
+ it("places a polite live region announcing the current step", () => {
+ const html = renderTour();
+ expect(html).toContain('aria-live="polite"');
+ });
+
+ it("German locale resolves the tour i18n keys end-to-end", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain("Schritt 1 von 5");
+ expect(html).toContain("Deine Tageswerte");
+ expect(html).toContain("Tour überspringen");
+ });
+});
diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx
new file mode 100644
index 000000000..5e46b4a3b
--- /dev/null
+++ b/src/components/onboarding/tour.tsx
@@ -0,0 +1,418 @@
+"use client";
+
+/**
+ * v1.4.15 Phase B5 — onboarding spotlight tour.
+ *
+ * After a new user finishes the wizard at `/onboarding` and lands on
+ * the dashboard, this overlay walks them through 4–5 key features
+ * (tile strip, quick-add menu, insights, integrations, achievements).
+ *
+ * Architecture decisions:
+ * - **No new dependency.** Project package.json carries no
+ * `react-joyride`/`intro.js`/`shepherd` already, and the tour is
+ * small enough that an in-house spotlight + tooltip is preferable
+ * to a 30 KB add. We render a fixed full-viewport overlay with a
+ * dark backdrop and one rectangular cutout aligned to the live
+ * target via measured `getBoundingClientRect()`. The tooltip is
+ * absolutely positioned next to the cutout.
+ * - **`data-tour-id` not class names.** Targets are looked up by a
+ * stable data attribute that's not affected by Tailwind churn.
+ * Each target's location is re-measured on `resize` and `scroll`
+ * so the cutout follows the element.
+ * - **Graceful degradation.** If the target element is missing
+ * (mobile breakpoint, conditional render), we drop the cutout and
+ * render the tooltip centred on screen so the user can still
+ * advance through the tour.
+ * - **Accessibility:** the dialog uses `role="dialog"` +
+ * `aria-modal="true"` + `aria-labelledby`, focus moves to the
+ * primary action on every step, ESC = skip, Tab is trapped inside
+ * the tooltip, and a live region announces the step transition.
+ * `prefers-reduced-motion` disables the cutout fade.
+ *
+ * The component is mounted by a sibling launcher that decides *when*
+ * to start the tour (server flag + localStorage). This component
+ * itself is dumb — caller hands it `onClose` (called on Skip OR Done)
+ * plus `outcome` (so the launcher can persist `completed` vs
+ * `skipped` to the API + audit log).
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import {
+ buildTourStops,
+ currentStop,
+ initTourState,
+ isTourFinished,
+ nextStep,
+ prevStep,
+ skipTour,
+ stepCounter,
+ type TourStop,
+} from "@/lib/onboarding/tour-state";
+
+interface TourProps {
+ /**
+ * Whether the achievements page exists and should be a tour stop.
+ * The launcher passes `false` if B4 hasn't shipped to skip the
+ * stop without renumbering.
+ */
+ includeAchievements?: boolean;
+ /**
+ * Called when the tour finishes (Done OR Skip OR Esc OR Backdrop
+ * click). Receives the terminal outcome so the launcher can audit
+ * the difference.
+ */
+ onClose: (outcome: "completed" | "skipped") => void;
+}
+
+interface SpotlightRect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Re-measure the target element. Returns `null` if the element is
+ * not in the DOM or has zero dimensions — caller renders a centred
+ * tooltip in that case.
+ */
+function measureTarget(targetId: string | null): SpotlightRect | null {
+ if (!targetId) return null;
+ if (typeof document === "undefined") return null;
+ const el = document.querySelector(
+ `[data-tour-id="${CSS.escape(targetId)}"]`,
+ );
+ if (!el) return null;
+ const rect = el.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return null;
+ // Pad the cutout slightly so the highlight isn't flush against
+ // the target's text.
+ const PAD = 8;
+ return {
+ top: rect.top - PAD,
+ left: rect.left - PAD,
+ width: rect.width + PAD * 2,
+ height: rect.height + PAD * 2,
+ };
+}
+
+/**
+ * Decide tooltip position. The intent is to sit beside the target
+ * along the requested axis, but flip to the opposite edge if there
+ * isn't enough room. Returns absolute viewport coordinates that the
+ * UI uses with `position: fixed`. Falls back to centred when there
+ * is no spotlight rect.
+ */
+function computeTooltipPosition(
+ rect: SpotlightRect | null,
+ placement: TourStop["placement"],
+ tooltipSize: { width: number; height: number },
+): { top: number; left: number; placement: TourStop["placement"] } {
+ if (typeof window === "undefined") return { top: 0, left: 0, placement };
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ if (!rect) {
+ return {
+ top: Math.max(16, (vh - tooltipSize.height) / 2),
+ left: Math.max(16, (vw - tooltipSize.width) / 2),
+ placement: "center",
+ };
+ }
+ const GAP = 12;
+ const candidates: Array<{
+ placement: TourStop["placement"];
+ top: number;
+ left: number;
+ }> = [];
+
+ if (placement === "bottom" || placement === "top" || placement === "center") {
+ candidates.push({
+ placement: "bottom",
+ top: rect.top + rect.height + GAP,
+ left: Math.min(
+ Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
+ vw - tooltipSize.width - 16,
+ ),
+ });
+ candidates.push({
+ placement: "top",
+ top: rect.top - tooltipSize.height - GAP,
+ left: Math.min(
+ Math.max(16, rect.left + rect.width / 2 - tooltipSize.width / 2),
+ vw - tooltipSize.width - 16,
+ ),
+ });
+ }
+ if (placement === "right" || placement === "left") {
+ candidates.push({
+ placement: "right",
+ top: Math.min(
+ Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
+ vh - tooltipSize.height - 16,
+ ),
+ left: rect.left + rect.width + GAP,
+ });
+ candidates.push({
+ placement: "left",
+ top: Math.min(
+ Math.max(16, rect.top + rect.height / 2 - tooltipSize.height / 2),
+ vh - tooltipSize.height - 16,
+ ),
+ left: rect.left - tooltipSize.width - GAP,
+ });
+ }
+ // Pick the first candidate that fits the viewport; otherwise the
+ // first candidate (the renderer will clamp inside the box).
+ for (const c of candidates) {
+ if (
+ c.top >= 8 &&
+ c.left >= 8 &&
+ c.top + tooltipSize.height <= vh - 8 &&
+ c.left + tooltipSize.width <= vw - 8
+ ) {
+ return c;
+ }
+ }
+ // Last-resort: centred bottom of viewport.
+ return {
+ placement: "center",
+ top: Math.max(16, (vh - tooltipSize.height) / 2),
+ left: Math.max(16, (vw - tooltipSize.width) / 2),
+ };
+}
+
+const TOOLTIP_WIDTH = 320;
+const TOOLTIP_HEIGHT = 220; // enough headroom for two-line title + 4-line body + footer
+
+export function OnboardingTour({
+ includeAchievements = true,
+ onClose,
+}: TourProps) {
+ const { t } = useTranslations();
+
+ const stops = useMemo(
+ () => buildTourStops({ includeAchievements }),
+ [includeAchievements],
+ );
+ const [state, setState] = useState(() => initTourState(stops));
+ const [rect, setRect] = useState(null);
+
+ const stop = currentStop(state);
+
+ // Re-measure on step change, viewport resize, and scroll. We intentionally
+ // *don't* MutationObserver the whole document — the targets are stable in
+ // the layout and re-measuring on rAF after scroll/resize is plenty.
+ useEffect(() => {
+ if (!stop) return;
+ let raf = 0;
+ const measure = () => {
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(() => {
+ setRect(measureTarget(stop.targetId));
+ });
+ };
+ measure();
+ window.addEventListener("resize", measure);
+ window.addEventListener("scroll", measure, true);
+ return () => {
+ cancelAnimationFrame(raf);
+ window.removeEventListener("resize", measure);
+ window.removeEventListener("scroll", measure, true);
+ };
+ }, [stop]);
+
+ // Focus management — moves focus to the primary action when the
+ // step changes so keyboard users can hit Enter to advance.
+ const primaryRef = useRef(null);
+ useEffect(() => {
+ if (!stop) return;
+ primaryRef.current?.focus();
+ }, [stop]);
+
+ // Keyboard shortcuts: Esc = skip, ArrowLeft / ArrowRight = nav,
+ // Enter = next/done. We attach to window so the listener works
+ // regardless of which element holds focus inside the tooltip.
+ const handleSkip = useCallback(() => {
+ setState((prev) => {
+ const next = skipTour(prev);
+ if (isTourFinished(next)) {
+ // Defer the parent onClose so it doesn't run inside the
+ // setState callback (which React calls during render in
+ // strict-mode dev).
+ queueMicrotask(() => onClose("skipped"));
+ }
+ return next;
+ });
+ }, [onClose]);
+
+ const handleNext = useCallback(() => {
+ setState((prev) => {
+ const next = nextStep(prev);
+ if (isTourFinished(next) && next.outcome === "completed") {
+ queueMicrotask(() => onClose("completed"));
+ }
+ return next;
+ });
+ }, [onClose]);
+
+ const handlePrev = useCallback(() => {
+ setState((prev) => prevStep(prev));
+ }, []);
+
+ useEffect(() => {
+ if (!stop) return;
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ handleSkip();
+ } else if (e.key === "ArrowRight" || e.key === "Enter") {
+ // Don't hijack Enter inside form inputs (none exist in the
+ // tour itself but be defensive against future content).
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable)
+ ) {
+ return;
+ }
+ e.preventDefault();
+ handleNext();
+ } else if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ handlePrev();
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [stop, handleSkip, handleNext, handlePrev]);
+
+ if (!stop) return null;
+
+ const counter = stepCounter(state);
+ const isLast = counter.current === counter.total;
+ const tooltipPos = computeTooltipPosition(rect, stop.placement, {
+ width: TOOLTIP_WIDTH,
+ height: TOOLTIP_HEIGHT,
+ });
+
+ // Build a clip-path that punches out the spotlight rect from the
+ // backdrop. `evenodd` fill rule + the outer rect winding the same
+ // way means the inner rect carves a hole. If `rect` is null we
+ // skip the cutout and render a solid backdrop.
+ const clipPath = rect
+ ? `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${rect.top}px, ${rect.left}px ${rect.top}px, ${rect.left}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top + rect.height}px, ${rect.left + rect.width}px ${rect.top}px, 0 ${rect.top}px)`
+ : undefined;
+
+ return (
+
+ {/* Backdrop with cutout. `pointer-events-auto` so a click on
+ the dimmed area dismisses the tour (treated as Skip). The
+ cutout itself uses `pointer-events-none` (transparent click
+ pass-through) on the dimmed region — but to keep the click
+ contract simple we use a single backdrop with the polygon
+ clip and let any backdrop click count as Skip. The actual
+ target underneath stays inert (the tour intentionally takes
+ focus). prefers-reduced-motion users get an instant overlay
+ (no opacity transition). */}
+
+
+ {/* Polite live region — announces the current step to screen readers. */}
+
+ );
+}
diff --git a/src/lib/onboarding/__tests__/tour-state.test.ts b/src/lib/onboarding/__tests__/tour-state.test.ts
new file mode 100644
index 000000000..450d73ce2
--- /dev/null
+++ b/src/lib/onboarding/__tests__/tour-state.test.ts
@@ -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 });
+ });
+ });
+});
diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts
new file mode 100644
index 000000000..a8776cfc5
--- /dev/null
+++ b/src/lib/onboarding/tour-state.ts
@@ -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=""]')`.
+ *
+ * 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..*`.
+ */
+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,
+ };
+}