diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index ae3571f52..ac1b786e5 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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, }); diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts new file mode 100644 index 000000000..a219aee23 --- /dev/null +++ b/src/app/api/onboarding/tour/route.ts @@ -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 }); +}); diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 8c2527c38..85a57ce20 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -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 { diff --git a/src/app/page.tsx b/src/app/page.tsx index 148ef5ee3..fedba8794 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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. */} - @@ -439,6 +444,16 @@ export default function DashboardPage() { * or fully complete. See B2 in the v1.4 discovery summary. */} + {/* 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. */} + + {/* Quick Entry Dialogs */} {trendCards.map((entry) => ( diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 833ea87a3..52260b43c 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -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() { …)` call in ``. 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(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(() => + 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 ( + { + // 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. + } + }} + /> + ); +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 877afe42e..31f172698 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -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 + * `` component for the gating logic. + */ + onboardingTourCompleted: boolean; gravatarUrl: string | null; glucoseUnit: string | null; }