From 58e9d1451ac6aa21951b44e294742bb55b2aa29c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 20:28:41 +0200 Subject: [PATCH] fix(dashboard): unwrap shared queryFn responses in onboarding checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GettingStartedChecklist intentionally shares TanStack Query cache with the dashboard via `queryKey: ["analytics"]` etc., but its queryFns returned the raw `res.json()` (the full `{data, error}` envelope) while the dashboard, medications page, notifications page and withings integrations section all return `json.data` (the unwrapped inner shape). When the checklist's queryFn populated the cache first, the dashboard's `data?.summaries?.WEIGHT` ended up reading a property of the envelope `{data, error}` instead of the analytics body, came back undefined, and silently failed every per-tile data gate (`hasWeight`, `hasBp`, `hasPulse`, `hasBodyFat`). Marc saw only the mood tile because mood lives under a different cache key (`["mood-analytics"]`). Same envelope/shape mismatch existed for `["medications"]`, `["withings", "status"]` and `["notifications", "preferences"]` — fixed in one pass since the failure mode is identical (silent undefined reads on shared keys, latent bug for any consumer that mounts after the checklist). Each queryFn now `await`s and returns `json.data`; the local accessors collapse from `res?.data?.X` to `res?.X`. As a side-effect this also fixes a long-standing checklist bug where `medsRes.data.medications.length` was always 0 (the medications API returns the array directly under `data:`, not nested under a `medications` field — so the count silently coerced to zero and the "log a medication" checklist item never auto-completed). Tests + typecheck stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../onboarding/getting-started-checklist.tsx | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 3bd7b4340..71c6cb32a 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -59,18 +59,12 @@ const ITEM_LABEL_KEYS: Record< }, }; -interface MedicationsResponse { - data?: { medications?: Array<{ id: string }> }; +interface AnalyticsData { + summaries?: Record; } -interface AnalyticsResponse { - data?: { - summaries?: Record; - }; -} - -interface WithingsStatusResponse { - data?: { connected?: boolean }; +interface WithingsStatus { + connected?: boolean; } interface NotificationChannel { @@ -78,8 +72,8 @@ interface NotificationChannel { enabled?: boolean; } -interface NotificationsResponse { - data?: { channels?: NotificationChannel[] }; +interface NotificationsPreferences { + channels?: NotificationChannel[]; } function readDismissedSet(): Set { @@ -150,59 +144,70 @@ export function GettingStartedChecklist() { // Light-weight queries: each fetch is small and cached by tanstack. // We rely on the React Query cache the dashboard already uses for // analytics, so this won't fire a second request when the dashboard - // and the checklist mount together. - const { data: analyticsRes } = useQuery({ + // and the checklist mount together. Critically, every queryFn here + // must return `json.data` (the unwrapped shape) so it matches the + // canonical consumer (dashboard / medications page / notifications + // page / integrations section). Returning `res.json()` would write + // the full `{data, error}` envelope into the shared cache, which then + // makes the canonical consumers' `data?.summaries` / `data?.length` + // reads silently return undefined — that's how the v1.4.2 dashboard + // ended up rendering only the mood tile. + const { data: analyticsData } = useQuery({ queryKey: ["analytics"], queryFn: async () => { const res = await fetch("/api/analytics"); if (!res.ok) throw new Error("Failed"); - return res.json(); + const json = await res.json(); + return json.data; }, enabled: !!user, }); - const { data: medsRes } = useQuery({ + const { data: medsData } = useQuery>({ queryKey: ["medications"], queryFn: async () => { const res = await fetch("/api/medications"); if (!res.ok) throw new Error("Failed"); - return res.json(); + const json = await res.json(); + return json.data; }, enabled: !!user, }); - const { data: withingsRes } = useQuery({ + const { data: withingsData } = useQuery({ queryKey: ["withings", "status"], queryFn: async () => { const res = await fetch("/api/withings/status"); if (!res.ok) throw new Error("Failed"); - return res.json(); + const json = await res.json(); + return json.data; }, enabled: !!user, }); - const { data: notificationsRes } = useQuery({ + const { data: notificationsData } = useQuery({ queryKey: ["notifications", "preferences"], queryFn: async () => { const res = await fetch("/api/notifications/preferences"); if (!res.ok) throw new Error("Failed"); - return res.json(); + const json = await res.json(); + return json.data; }, enabled: !!user, }); const measurementCount = useMemo(() => { - const summaries = analyticsRes?.data?.summaries ?? {}; + const summaries = analyticsData?.summaries ?? {}; let total = 0; for (const key of Object.keys(summaries)) { total += summaries[key]?.count ?? 0; } return total; - }, [analyticsRes]); + }, [analyticsData]); - const medicationCount = medsRes?.data?.medications?.length ?? 0; - const withingsConnected = withingsRes?.data?.connected === true; - const notificationsConfigured = (notificationsRes?.data?.channels ?? []).some( + const medicationCount = medsData?.length ?? 0; + const withingsConnected = withingsData?.connected === true; + const notificationsConfigured = (notificationsData?.channels ?? []).some( (channel) => channel?.enabled === true, );