"use client"; import { useAiProviderState } from "@/hooks/use-ai-capability"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; import { Activity, Bell, Check, ChevronDown, Pill, Sparkles, Stethoscope, User2, UserPlus, Wifi, X, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAccountOnceMounted } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { queryKeys } from "@/lib/query-keys"; import { buildChecklist, checklistProgress, isStillInSetup, shouldShowChecklist, upcomingVisitCountFrom, visibleChecklist, type ChecklistItemId, } from "@/lib/onboarding/checklist"; import { apiGet } from "@/lib/api/api-fetch"; import { CHECKLIST_DISMISSED_ALL_KEY as DISMISSED_ALL_KEY, CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY, CHECKLIST_EXPANDED_KEY as EXPANDED_KEY, } from "@/lib/onboarding/checklist-storage"; import { questionOptionLabelKey } from "@/lib/onboarding/question-config"; import { isBrowserConnectableSource } from "@/lib/onboarding/wizard-steps"; const ITEM_ICONS: Record = { profile: User2, measurement: Activity, medication: Pill, dataSource: Wifi, notifications: Bell, insights: Sparkles, visit: Stethoscope, managedProfile: UserPlus, }; const ITEM_LABEL_KEYS: Record< ChecklistItemId, { title: string; description: string; cta: string } > = { profile: { title: "gettingStarted.items.profileTitle", description: "gettingStarted.items.profileDescription", cta: "gettingStarted.items.profileCta", }, measurement: { title: "gettingStarted.items.measurementTitle", description: "gettingStarted.items.measurementDescription", cta: "gettingStarted.items.measurementCta", }, medication: { title: "gettingStarted.items.medicationTitle", description: "gettingStarted.items.medicationDescription", cta: "gettingStarted.items.medicationCta", }, dataSource: { title: "gettingStarted.items.dataSourceTitle", description: "gettingStarted.items.dataSourceDescription", cta: "gettingStarted.items.dataSourceCta", }, notifications: { title: "gettingStarted.items.notificationsTitle", description: "gettingStarted.items.notificationsDescription", cta: "gettingStarted.items.notificationsCta", }, insights: { title: "gettingStarted.items.insightsTitle", description: "gettingStarted.items.insightsDescription", cta: "gettingStarted.items.insightsCta", }, visit: { title: "gettingStarted.items.visitTitle", description: "gettingStarted.items.visitDescription", cta: "gettingStarted.items.visitCta", }, managedProfile: { title: "gettingStarted.items.managedProfileTitle", description: "gettingStarted.items.managedProfileDescription", cta: "gettingStarted.items.managedProfileCta", }, }; interface IntegrationsStatus { integrations?: Array<{ connected?: boolean; enabled?: boolean }>; } interface NotificationChannel { type?: string; enabled?: boolean; } interface NotificationsPreferences { channels?: NotificationChannel[]; } function readDismissedSet(): Set { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(DISMISSED_ITEMS_KEY); if (!raw) return new Set(); const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { return new Set( parsed.filter((id): id is ChecklistItemId => typeof id === "string"), ); } } catch { /* ignore corrupt storage */ } return new Set(); } function readDismissedAll(): boolean { if (typeof window === "undefined") return false; try { return window.localStorage.getItem(DISMISSED_ALL_KEY) === "1"; } catch { return false; } } function readExpanded(): boolean { // Default = collapsed. v1.4.15 phase-A3 fix #3: the card auto-opening // on every page-load was the dominant complaint after the flicker — // it pushed the actual dashboard tiles below the fold every visit. // We persist the user's last choice so a deliberate expand survives // reloads, but the *default* for a brand-new user (no key set) is // collapsed. if (typeof window === "undefined") return false; try { return window.localStorage.getItem(EXPANDED_KEY) === "1"; } catch { return false; } } /** * Persistent dashboard checklist surfaced to brand-new users. Mirrors * the Linear/Notion pattern: visible until the user has either * completed every step or explicitly hidden it. Each row is one line * with status icon + label + CTA + dismiss-x. */ export function GettingStartedChecklist() { // Withheld until mounted: this card lives inside the dashboard's streamed // boundary and its whole render turns on `if (!user) return null` below. The // shell has usually answered `/api/auth/me` before the boundary hydrates, so // reading the payload on the hydration render would paint a card where the // server painted nothing — a mismatch that discards the streamed dashboard. // See `useAccountOnceMounted`. const user = useAccountOnceMounted(); const { t } = useTranslations(); const [dismissedIds, setDismissedIds] = useState>(() => readDismissedSet(), ); const [dismissedAll, setDismissedAll] = useState(() => readDismissedAll(), ); // v1.4.15 phase-A3 fix #3 — collapsed by default. The card used to // render expanded on every load, pushing actual dashboard tiles below // the fold. The collapsed shell still surfaces the progress meter + // CTA-row count via `gettingStarted.progress`, so users see at a // glance how much setup is left without the visual noise. const [expanded, setExpanded] = useState(() => readExpanded()); // Sync state to localStorage. Effect, not setState-in-effect. useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.setItem( DISMISSED_ITEMS_KEY, JSON.stringify([...dismissedIds]), ); } catch { /* storage may be full or disabled */ } }, [dismissedIds]); useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.setItem(DISMISSED_ALL_KEY, dismissedAll ? "1" : "0"); } catch { /* storage may be full or disabled */ } }, [dismissedAll]); useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.setItem(EXPANDED_KEY, expanded ? "1" : "0"); } catch { /* storage may be full or disabled */ } }, [expanded]); // v1.15.20 — the measurement count is derived from the dashboard // snapshot the page already fetches (`tiles.summaries` is the same // `computeSummariesSlice` payload the legacy `?slice=summaries` // analytics query carried), so the checklist no longer fires its own // analytics request on every dashboard load. Subscribing to the shared // `queryKeys.dashboardSnapshot()` cell costs zero extra network. const snapshotQuery = useDashboardSnapshot(!!user); const snapshotData = snapshotQuery.data; const measurementCount = useMemo(() => { const summaries = snapshotData?.tiles?.summaries ?? {}; let total = 0; for (const key of Object.keys(summaries)) { total += summaries[key]?.count ?? 0; } return total; }, [snapshotData]); // v1.15.20 — only fetch the checklist's supporting data while the card // can actually render: the user is still in setup and has not dismissed // it. Established users used to pay this medications fetch (and, pre-v1.5, // two more) on every dashboard load for a card that never mounts. Gated on // the snapshot having resolved so the count is real, not the // loading-default 0. // // v1.39 — the rule is `isStillInSetup`, the same predicate // `shouldShowChecklist` applies, rather than a restatement of half of it. // The two drifted apart once already: the visibility rule learned about // records that ran the setup flow and this gate did not, so the card // rendered for a wizard-run account past five readings with every // network-backed row frozen at "not done". const checklistRelevant = !!user && !dismissedAll && snapshotData !== undefined && isStillInSetup({ onboarding: user.onboarding ?? null, onboardingCompletedAt: user.onboardingCompletedAt ?? null, measurementCount, }); const { data: medsData } = useQuery>({ queryKey: queryKeys.medications(), queryFn: async () => { return apiGet("/api/medications"); }, enabled: checklistRelevant, }); // The AI row reads the provider state `/api/auth/me` already carries: // present only while AI can be set up from here (`canConfigure`), done once // any provider is configured (a personal key, a local model, an OAuth // sign-in or the operator's shared key). No request of its own. const aiProvider = useAiProviderState(); // v1.5 perf audit: skip these two fetches once the card can't render at // all (`checklistRelevant`, same gate the meds/AI queries above use). // withings/status and notifications/preferences are unique to this // component and were burning ~950 ms of network on every dashboard load // for established users. See docs/audit/v15-performance.md. // // 2026-07-17 UX audit M1 — this used to read a separate `onboardingPending` // (`onboardingCompletedAt == null`) instead of `checklistRelevant`, so a // user who finished the wizard but still had under 5 measurements kept // seeing the card (`shouldShowChecklist` allows that) while these two // queries stayed disabled forever: "Connect a data source" and // "Configure notifications" could never flip done even after the user // actually connected Withings or a push channel post-wizard. // v1.17.0 — any connected data source satisfies the step, so we read // the consolidated integrations envelope (Withings / WHOOP / Fitbit / // Google Health / …) rather than the Withings-only status route. const { data: integrationsData } = useQuery({ queryKey: queryKeys.integrationsStatus(), queryFn: async () => { return apiGet("/api/integrations/status"); }, enabled: checklistRelevant, }); const { data: notificationsData } = useQuery({ queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { return apiGet("/api/notifications/preferences"); }, enabled: checklistRelevant, }); // v1.39 (C2) — the "prepare the visit" row, only for the answer that asked // for it: a visit within a month. Reads the visits list's own meta count so // the row flips done the moment one is on the calendar. // Answers given for somebody else's record (Q1 "someone I look after") are // the child's, not this dashboard's: no visit row, no named source here. const answersAreOwn = user?.onboarding?.needs.recordTarget !== "someone-else"; const visitAsked = answersAreOwn && user?.onboarding?.needs.visit === "within-a-month"; const { data: upcomingVisitCount } = useQuery({ queryKey: queryKeys.onboardingUpcomingVisits(), queryFn: async () => upcomingVisitCountFrom( await apiGet<{ upcoming: unknown[] }>("/api/encounters"), ), enabled: checklistRelevant && visitAsked, }); // The named source: "Connect Oura" rather than "Connect a data source" // for somebody who said their readings come from one. const namedSource = answersAreOwn ? (user?.onboarding?.needs.sources.find(isBrowserConnectableSource) ?? null) : null; const managedProfileCount = (user?.accountAccess?.accounts ?? []).filter( (entry) => entry.recordKind === "managed", ).length; const medicationCount = medsData?.length ?? 0; const dataSourceConnected = (integrationsData?.integrations ?? []).some( (integration) => integration?.connected === true || integration?.enabled === true, ); const notificationsConfigured = (notificationsData?.channels ?? []).some( (channel) => channel?.enabled === true, ); const insightsConfigured = aiProvider.configured; const aiConfigurable = aiProvider.canConfigure; const items = useMemo( () => buildChecklist({ profile: { heightCm: user?.heightCm ?? null, dateOfBirth: user?.dateOfBirth ?? null, gender: user?.gender ?? null, }, measurementCount, medicationCount, dataSourceConnected, notificationsConfigured, insightsConfigured, aiConfigurable, dismissedIds, upcomingVisitCount: upcomingVisitCount ?? 0, managedProfileCount, // v1.39 (C1) — the setup answers order these rows: medication first // for somebody who said they take one daily, the data-source row first // for somebody who named a wearable. The payload resolves them for the // ACTIVE record, which is the same record the counts above describe. // Null until the payload resolves, and for a record that never entered // the flow. onboarding: user?.onboarding ?? null, }), [ user?.heightCm, user?.dateOfBirth, user?.gender, user?.onboarding, measurementCount, medicationCount, dataSourceConnected, notificationsConfigured, insightsConfigured, aiConfigurable, dismissedIds, upcomingVisitCount, managedProfileCount, ], ); const visible = visibleChecklist(items); const progress = checklistProgress(items); const show = shouldShowChecklist({ onboardingCompletedAt: user?.onboardingCompletedAt ?? null, measurementCount, dismissedAll, items, // v1.39 (C1) — five readings are not evidence the setup finished. While a // step is still pending, the answers were never confirmed, or the one task // the flow offered never produced its result, the list stays. onboarding: user?.onboarding ?? null, }); // v1.4.15 phase-A3 fix #3 — flicker guard. Until BOTH the auth user // AND the snapshot query have resolved we render NOTHING. The // previous version rendered a default-true `show` branch while // `measurementCount` was still 0 (because the data hadn't returned), // so a user whose actual `measurementCount >= 5` saw the card flash // for ~500 ms before `shouldShowChecklist` flipped to false. We rely // on `snapshotData === undefined` as the loading sentinel — // tanstack-query writes `data` exactly once per fetch, so this is // race-free across hot-reloads and stale-cache invalidation. (The // dashboard's snapshot query shares the cache key, so the checklist // almost always sees the cached value synchronously on mount and // never blocks UI.) if (!user) return null; if (snapshotData === undefined) return null; if (!show) return null; return (
{/* v1.4.27 MB7 / CF-44 — `flex-wrap` on the header lets the dismiss button drop to its own row when the German title + subtitle overflow the visible width on Galaxy Fold / Pixel 5 (the previous `flex` layout collided the dismiss caption against the chevron). `min-w-0` on the toggle button lets the inner `

` shrink + truncate cleanly instead of pushing the chevron off-screen. */}
{/* Progress meter — non-decorative, exposed to screen readers. Stays visible in both expanded and collapsed states so a glance at the card tells the user how much setup is left without forcing the full row-list back open. */}
{t("gettingStarted.progress", { done: progress.done, total: progress.total, })} {progress.percent}%
{expanded ? (
    {visible.map((item) => { const Icon = ITEM_ICONS[item.id]; const labels = ITEM_LABEL_KEYS[item.id]; return (
  • {item.id === "dataSource" && namedSource ? t("gettingStarted.items.dataSourceNamedTitle", { source: t( questionOptionLabelKey("sources", namedSource), ), }) : t(labels.title)}

    {t(labels.description)}

    {!item.done ? ( ) : null}
  • ); })}
) : null}

); }