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). */} + +
+ {counter.current > 1 && ( + + )} + +
+ +
+ + ); +} 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, + }; +}