feat(onboarding): auto-launch tour for new users on first dashboard load

v1.4.15 Phase B5 — third commit. Wires the spotlight tour
(`<OnboardingTour>` from previous commit) to auto-fire for new users
the first time they land on the dashboard, persists completion to
the DB, and exposes a window-event hook for replay.

Pieces:

  - `<TourLauncher>` (`src/components/onboarding/tour-launcher.tsx`)
    — gates and mounts the tour. Self-decides via:
      * `user.onboardingTourCompleted` (DB flag from migration 0030)
      * sessionStorage dismiss guard (defensive against in-flight
        `/api/auth/me` refetches re-opening the tour after Skip)
      * post-wizard 1500ms grace window (avoids the wizard's
        `doneToast` and the spotlight stacking on the same z-layer)
      * a `ready` prop the dashboard sets once analytics has
        resolved (so the spotlight cutout doesn't snap to a 0×0
        placeholder before tiles render)
    Listens for `healthlog:tour-restart` to force-reopen.

  - `POST /api/onboarding/tour` — single endpoint with body
    `{ completed: boolean, outcome?: "completed" | "skipped" }`.
    Flips `users.onboarding_tour_completed`. Dispatches a Wide
    Event annotation with the outcome so Loki can split completion
    from dismiss without a second column.

  - `/api/auth/me` now returns `onboardingTourCompleted`. The
    `AuthUser` type in `src/hooks/use-auth.ts` gains the field;
    no consumer outside the launcher reads it today.

  - `data-tour-id` attributes added to:
      * dashboard tile-strip wrapper (`dashboard-tile-strip`)
      * dashboard "Add" dropdown trigger (`dashboard-quick-add`)
      * sidebar nav items (`nav-insights`, `nav-settings`,
        `nav-achievements`, plus the rest for symmetry —
        targeted attribute, not class names, so renames in
        Tailwind classes don't silently break the spotlight)

  - The `/onboarding` wizard's `persistAndExit()` writes
    `sessionStorage["healthlog-tour-referrer"] = "1"` immediately
    before navigating to `/`. The launcher consumes-and-clears that
    on first mount via a `useState` lazy initialiser (presence-only
    flag — no `Date.now()` so the render-phase decision stays pure
    under `react-hooks/purity`).

ESLint compliance:

  - `react-hooks/set-state-in-effect` — no synchronous setState in
    a useEffect body. The launch decision happens in the render
    phase using the React-recommended "previous input id" guard
    (mirrors `account-section.tsx`'s `seededUserId`), and the
    post-wizard timer's setState lives inside a setTimeout
    callback (an event handler, not the effect body).
  - `react-hooks/purity` — render-phase setState only branches on
    prop values + sessionStorage-presence helpers. No `Date.now()`
    / `Math.random()` / fetch in render.

Tests: my 39 unit tests pass (was 27 — +12 across tour-state and
tour SSR shape). Pre-existing test failures in
`doctor-report-data.test.ts` are uncommitted B6 work in the
working tree, not B5. Lint clean for B5 files; one pre-existing
B6 lint error in `doctor-report-dialog.tsx` is out of scope.
Typecheck has the same pre-existing `dashboard-layout.test.ts`
errors documented in STATE.md (A4 / phase-D scope).

Restart UX (Settings → Account → "Restart tour") lands in the next
commit (B5-4) so this commit stays orthogonal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:28:51 +02:00
1 parent 53561d3ad5
commit 8215e25876
7 files changed
+359 -8

No files matched your search

+1
View File
@@ -20,6 +20,7 @@ export const GET = apiHandler(async () => {
gender: user.gender,
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
glucoseUnit: user.glucoseUnit ?? null,
});
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
/**
* v1.4.15 Phase B5 — onboarding tour completion endpoint.
*
* Two operations on a single resource:
*
* POST { completed: true, outcome: "completed" | "skipped" }
* → flips `users.onboarding_tour_completed` to true. Auto-fires
* when the user reaches the end of the spotlight tour OR
* explicitly dismisses it.
*
* POST { completed: false }
* → resets the flag so the user can replay the tour from
* Settings → Account → "Restart onboarding tour".
*
* The `outcome` field is informational — it lands in the structured
* Wide Event annotation so analytics can tell completion from
* dismiss without writing two columns. Same DB write either way.
*/
const tourBodySchema = z.object({
completed: z.boolean(),
outcome: z.enum(["completed", "skipped"]).optional(),
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = z.safeParse(tourBodySchema, body);
if (!parsed.success) {
return apiError("Invalid input", 422);
}
await prisma.user.update({
where: { id: user.id },
data: { onboardingTourCompleted: parsed.data.completed },
});
annotate({
action: { name: "onboarding.tour.update" },
meta: {
completed: parsed.data.completed,
outcome: parsed.data.outcome ?? null,
},
});
return apiSuccess({ onboardingTourCompleted: parsed.data.completed });
});
+14
View File
@@ -121,6 +121,20 @@ export default function OnboardingPage() {
await queryClient.invalidateQueries({ queryKey: ["auth"] });
toast.success(t("onboarding.v2.doneToast"));
// v1.4.15 Phase B5: leave a presence flag so the dashboard's
// tour launcher knows the user JUST came from the wizard and
// delays the spotlight by ~1.5s. Avoids the doneToast and
// tour overlay stacking on the same z-layer. We use a
// presence-only `"1"` (not a timestamp) so the launcher's
// render-phase decision can stay pure under
// `react-hooks/purity` — the launcher consumes-and-clears the
// key on first read.
try {
window.sessionStorage.setItem("healthlog-tour-referrer", "1");
} catch {
/* ignore — storage disabled or full */
}
if (opts?.gotoSettingsHash) {
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
} else {
+17 -1
View File
@@ -42,6 +42,7 @@ import { TrendCard } from "@/components/charts/trend-card";
import { TrendHint } from "@/components/charts/trend-hint";
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
const HealthChart = dynamic(
@@ -406,7 +407,11 @@ export default function DashboardPage() {
`size="sm"` (h-8 = 32px) was below threshold; the explicit
min-h-11 ensures we hit 44px on the Pixel 5 viewport while
keeping the desktop visual unchanged. */}
<Button size="sm" className="min-h-11">
<Button
size="sm"
className="min-h-11"
data-tour-id="dashboard-quick-add"
>
<Plus className="mr-1 h-4 w-4" />
{t("common.add")}
</Button>
@@ -439,6 +444,16 @@ export default function DashboardPage() {
* or fully complete. See B2 in the v1.4 discovery summary. */}
<GettingStartedChecklist />
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
* a session-storage dismiss guard. We pass `ready=true` only
* after analytics has resolved — the tour anchors to the tile
* strip and we don't want the cutout snapping to a 0×0
* placeholder before tiles render. The launcher mounts a no-op
* `null` when the user has already seen the tour, so this
* line is free for established users. */}
<TourLauncher ready={data !== undefined} />
{/* Quick Entry Dialogs */}
<Dialog
open={quickEntryDialog === "measurement"}
@@ -963,6 +978,7 @@ export default function DashboardPage() {
// it preserves.
className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2"
data-slot="dashboard-tile-strip"
data-tour-id="dashboard-tile-strip"
data-tile-count={trendCards.length}
>
{trendCards.map((entry) => (
+34 -7
View File
@@ -51,13 +51,36 @@ import {
const STORAGE_KEY = "healthlog-sidebar-collapsed";
const navItems = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
{ href: "/targets", tKey: "nav.targets", icon: Target },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
{ href: "/", tKey: "nav.dashboard", icon: Home, tourId: "nav-dashboard" },
{
href: "/measurements",
tKey: "nav.measurements",
icon: Activity,
tourId: "nav-measurements",
},
{ href: "/mood", tKey: "nav.mood", icon: Waves, tourId: "nav-mood" },
{
href: "/medications",
tKey: "nav.medications",
icon: Pill,
tourId: "nav-medications",
},
// v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups
// performed by the onboarding tour. Keep these stable — renaming
// them silently breaks the spotlight cutout for that step.
{
href: "/insights",
tKey: "nav.insights",
icon: Lightbulb,
tourId: "nav-insights",
},
{ href: "/targets", tKey: "nav.targets", icon: Target, tourId: "nav-targets" },
{
href: "/achievements",
tKey: "nav.achievements",
icon: Trophy,
tourId: "nav-achievements",
},
];
function getInitials(name: string): string {
@@ -335,6 +358,7 @@ export function SidebarNav() {
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
isActive
@@ -357,6 +381,7 @@ export function SidebarNav() {
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
@@ -427,6 +452,7 @@ export function SidebarNav() {
aria-current={
pathname.startsWith("/settings") ? "page" : undefined
}
data-tour-id="nav-settings"
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
pathname.startsWith("/settings")
@@ -514,6 +540,7 @@ export function SidebarNav() {
aria-current={
pathname.startsWith("/settings") ? "page" : undefined
}
data-tour-id="nav-settings"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
pathname.startsWith("/settings")
+228
View File
@@ -0,0 +1,228 @@
"use client";
/**
* v1.4.15 Phase B5 — onboarding-tour launcher.
*
* 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.
*
* Gating rules:
*
* 1. Wait until the auth payload (`/api/auth/me`) has resolved so
* we know `onboardingTourCompleted`.
* 2. Wait until the dashboard's analytics fetch has resolved.
* The tour spotlights the tile strip — if we open before tiles
* have rendered, the cutout snaps to a 0×0 invisible target
* and the tooltip fallbacks to centre-screen, breaking the
* first impression. The launcher subscribes to the same
* `["analytics"]` query the dashboard uses; the data resolves
* synchronously on subsequent visits via the React Query cache.
* 3. Don't auto-launch within 1500ms of the user navigating away
* from `/onboarding`. The wizard ends with a green-tick toast
* that overlaps the same z-index range; if the tour pops up
* mid-toast it reads as a double overlay.
* 4. Honour a localStorage dismiss flag for the *current session*.
* Mostly defensive — when the API call to flip the DB flag
* races with an in-flight `/api/auth/me` refetch, the cached
* response could re-open the tour. The localStorage guard
* blocks that for the rest of the session.
*
* Replay flow: the Settings → Account "Restart" button posts
* `{ completed: false }` to /api/onboarding/tour, then dispatches a
* `healthlog:tour-restart` window event. This launcher listens for
* that event and force-opens the tour without reading the flag —
* convenient because invalidating the auth cache + waiting for the
* round-trip would feel laggy.
*/
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { OnboardingTour } from "./tour";
const SESSION_DISMISS_KEY = "healthlog-tour-session-dismissed";
const POST_WIZARD_GRACE_MS = 1500;
const REFERRER_KEY = "healthlog-tour-referrer";
/**
* Whether the user just navigated here from `/onboarding`. Read
* once at mount via a `useState` lazy initialiser (which runs in a
* pure-by-contract slot, not the render body) — see the
* `useState(() => …)` call in `<TourLauncher>`. The wizard's
* `persistAndExit()` writes `"1"` into sessionStorage immediately
* before navigating, and the launcher consumes-and-clears it on
* first mount so a back-button to the wizard and forward again
* doesn't double-defer.
*/
function readAndClearJustFromWizard(): boolean {
if (typeof window === "undefined") return false;
try {
const value = window.sessionStorage.getItem(REFERRER_KEY);
if (value !== "1") return false;
window.sessionStorage.removeItem(REFERRER_KEY);
return true;
} catch {
return false;
}
}
function readSessionDismissed(): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(SESSION_DISMISS_KEY) === "1";
} catch {
return false;
}
}
function writeSessionDismissed() {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(SESSION_DISMISS_KEY, "1");
} catch {
/* ignore — sandboxed iframes etc. */
}
}
function clearSessionDismissed() {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(SESSION_DISMISS_KEY);
} catch {
/* ignore */
}
}
interface TourLauncherProps {
/**
* The dashboard passes a sentinel — true once analytics has
* resolved at least once. Without this gate the spotlight would
* try to anchor to tiles that haven't rendered yet, and Recharts
* widgets defer mounting too. This is a simple, race-free signal
* the dashboard already computes.
*/
ready: boolean;
}
export function TourLauncher({ ready }: TourLauncherProps) {
const { user, isAuthenticated, isLoading } = useAuth();
const queryClient = useQueryClient();
// `null` = decision not yet made; `true` = render the tour;
// `false` = don't render. We only flip null→true ONCE per mount;
// closing the tour transitions true→false and we never re-open
// unless the restart event fires.
const [showTour, setShowTour] = useState<boolean | null>(null);
// Lazy initial check for "user just exited the wizard". The
// initialiser runs once at mount — sessionStorage is pure-enough
// there because React only invokes the initialiser one time per
// component instance, and in dev StrictMode's double-call merely
// returns the same `true` (the second call sees the cleared key
// and returns `false`, but the state has already been seeded). We
// accept that tiny behavioural quirk because it only affects dev
// mode and the consequence is "tour doesn't auto-launch on first
// dev mount" — never surfaces in production.
const [justFromWizard] = useState<boolean>(() =>
readAndClearJustFromWizard(),
);
// 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);
// Tracks the (userId, flag) tuple the launch-decision evaluator
// has already run for. The render-phase decision below uses this
// as the React-recommended "previous input id" guard so the
// setState block runs at most once per (user, flag) transition.
const [decidedFor, setDecidedFor] = useState<{
userId: string;
flag: boolean;
} | null>(null);
const inputsReady = !isLoading && isAuthenticated && !!user && ready;
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
// `seededUserId` — React-recommended for "compute initial state
// from props once they arrive"). Pure: branches on prop values
// and constant-result helpers only.
if (
showTour === null &&
inputsReady &&
user &&
(decidedFor === null ||
decidedFor.userId !== user.id ||
decidedFor.flag !== user.onboardingTourCompleted)
) {
setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted });
if (user.onboardingTourCompleted) {
setShowTour(false);
} else if (readSessionDismissed()) {
setShowTour(false);
} else if (justFromWizard) {
// Mark "deferred"; the effect below schedules a fixed-duration
// timer + flips `showTour` from inside the timeout callback
// (which the set-state-in-effect rule allows).
setShowTour(false);
setDeferredFromWizard(true);
} else {
setShowTour(true);
}
}
// Wait out the post-wizard grace window. Side-effect-only (timer
// management); the inner setState lives inside a callback so the
// set-state-in-effect rule is satisfied.
useEffect(() => {
if (!deferredFromWizard) return;
const id = window.setTimeout(() => {
if (!readSessionDismissed()) {
setShowTour(true);
}
setDeferredFromWizard(false);
}, POST_WIZARD_GRACE_MS);
return () => window.clearTimeout(id);
}, [deferredFromWizard]);
// Listen for explicit restart events from Settings → Account.
// setState inside a window-event listener is fine under the
// set-state-in-effect rule.
useEffect(() => {
function onRestart() {
clearSessionDismissed();
setShowTour(true);
}
window.addEventListener("healthlog:tour-restart", onRestart);
return () => {
window.removeEventListener("healthlog:tour-restart", onRestart);
};
}, []);
if (showTour !== true) return null;
return (
<OnboardingTour
onClose={async (outcome) => {
// Optimistic: hide the overlay immediately, persist in the
// background. The flag is idempotent so re-fires are safe;
// a network error here is non-fatal because the
// session-dismiss guard prevents the tour from re-launching
// 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.
setShowTour(false);
writeSessionDismissed();
try {
await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: true, outcome }),
});
await queryClient.invalidateQueries({ queryKey: ["auth"] });
} catch {
// Swallow — the session-dismiss flag prevents a re-open.
// Next visit will retry via the same code path.
}
}}
/>
);
}
+7
View File
@@ -13,6 +13,13 @@ export interface AuthUser {
gender: string | null;
timezone: string;
onboardingCompletedAt: string | null;
/**
* v1.4.15 Phase B5: whether the user has finished or dismissed
* the spotlight tour overlaid on the dashboard. Distinct from
* `onboardingCompletedAt` (the wizard at /onboarding) — see the
* `<TourLauncher>` component for the gating logic.
*/
onboardingTourCompleted: boolean;
gravatarUrl: string | null;
glucoseUnit: string | null;
}