fix(dashboard): unwrap shared queryFn responses in onboarding checklist

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-08 20:28:41 +02:00
1 parent 0810a859e1
commit 58e9d1451a
1 file changed
+31 -26
@@ -59,18 +59,12 @@ const ITEM_LABEL_KEYS: Record<
},
};
interface MedicationsResponse {
data?: { medications?: Array<{ id: string }> };
interface AnalyticsData {
summaries?: Record<string, { count?: number } | undefined>;
}
interface AnalyticsResponse {
data?: {
summaries?: Record<string, { count?: number } | undefined>;
};
}
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<ChecklistItemId> {
@@ -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<AnalyticsResponse>({
// 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<AnalyticsData>({
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<MedicationsResponse>({
const { data: medsData } = useQuery<Array<{ id: string }>>({
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<WithingsStatusResponse>({
const { data: withingsData } = useQuery<WithingsStatus>({
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<NotificationsResponse>({
const { data: notificationsData } = useQuery<NotificationsPreferences>({
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,
);