From 3126e4cff2e72a9bd9174d31e82ef64b08b67ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 09:55:15 +0200 Subject: [PATCH] perf(dashboard): split analytics fan-out across slim and thick queries Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the thick `/api/analytics` envelope. Every per-type tile on the dashboard strip waited on the heavy fan-out before paint. Mood and medication tiles arrived first (separate routes with their own dedicated 60 s LRU caches); every other tile then arrived as one delayed burst once the full slice landed. Marc reported the "blocked-then-burst" pattern as "etwas nervig" in the v1.4.39.1 post-deploy trace. The dashboard now mounts two `useAnalyticsQuery` calls in parallel: one for `?slice=summaries` (per-type tile strip + last-seen freshness map) and one for the thick envelope (BD-Zielbereich + glucose tiles). The tile strip paints as soon as the slim slice resolves; the BD-Zielbereich and glucose tiles stream in afterwards from the thick slice. TanStack's parallel-mounting keeps the network fan-out flat, and both queries share the `caches.analytics` 60 s LRU server-side so warm hits stay free. The `data` object the rest of the dashboard consumes stays shape- stable: a `useMemo` merges the two query results with slim winning on overlapping fields (slim and thick compute `summaries` / `lastSeenByType` from the same SQL helpers, so the values agree). The thick-only `bpInTargetPct*` and `glucoseByContext` fields read from the thick slice and surface as `null` / `undefined` until the thick query resolves; the BD-Zielbereich tile's data-floor gate already suppresses the tile cleanly in that interim state. A text-anchored test pins both query mounts so a future refactor cannot silently collapse the dashboard back onto the thick-only path. --- .../dashboard-analytics-split.test.ts | 52 ++++++++++++++++ src/app/page.tsx | 59 ++++++++++++++++--- 2 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 src/app/__tests__/dashboard-analytics-split.test.ts diff --git a/src/app/__tests__/dashboard-analytics-split.test.ts b/src/app/__tests__/dashboard-analytics-split.test.ts new file mode 100644 index 000000000..6ab16f5a1 --- /dev/null +++ b/src/app/__tests__/dashboard-analytics-split.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.4.39.2 — dashboard analytics slim/thick split. + * + * Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the + * thick `/api/analytics` envelope; every per-type tile waited on the + * heavy fan-out before paint. Mood and medication tiles arrived first + * (separate routes) and then every other tile arrived as one burst, + * which Marc reported as "etwas nervig" in the v1.4.39.1 post-deploy + * trace. + * + * Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in + * parallel: one for `?slice=summaries` (per-type tile strip) and one + * for the thick envelope (BD-Zielbereich + glucose tiles). The tile + * strip paints from the slim slice as soon as it lands; thick fields + * stream in afterwards. Without this dual-mount the cold-mount UX + * waterfall regresses to the v1.4.39.1 behaviour, so the test pins the + * exact two call shapes. + */ +const ROOT = join(__dirname, "../../.."); +const PAGE_PATH = join(ROOT, "src/app/page.tsx"); + +function load(path: string): string { + return readFileSync(path, "utf8"); +} + +describe("v1.4.39.2 — dashboard analytics slim/thick split", () => { + it("mounts both the slim slice and the thick slice in parallel", () => { + const src = load(PAGE_PATH); + // Slim slice — `?slice=summaries` paints the per-type tile strip. + expect(src).toMatch(/useAnalyticsQuery\(\{\s*slice:\s*"summaries"\s*\}\)/); + // Thick slice — the bare `useAnalyticsQuery()` (no slice) feeds + // the BD-Zielbereich + glucose tiles. Both must coexist. + expect(src).toMatch(/useAnalyticsQuery\(\)/); + }); + + it("merges slim and thick results so call-sites stay shape-stable", () => { + const src = load(PAGE_PATH); + // The merge contract: slim wins on overlapping fields, thick fills + // in `bpInTargetPct*` and `glucoseByContext`. The text anchors + // protect against a future refactor accidentally dropping one of + // the queries — the `data` object that downstream tile rendering + // consumes must stay shape-stable across both query states. + expect(src).toMatch(/analyticsSlimQuery/); + expect(src).toMatch(/analyticsThickQuery/); + expect(src).toMatch(/bpInTargetPct/); + expect(src).toMatch(/glucoseByContext/); + }); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 91968ac27..39498c76a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { @@ -222,14 +222,55 @@ export default function DashboardPage() { refetchOnWindowFocus: false, } as const; - // v1.4.33 IW2 — the dashboard tile-strip reads `bpInTargetPct*` + - // `glucoseByContext` (thick-only fields) so it stays on the default - // thick slice. The shared hook still centralises the cache settings - // (60s staleTime, no refetch on mount / window focus) so the - // tile-strip dedups with the Insights mother page on the same - // queryKey + slice combination. - const analyticsQuery = useAnalyticsQuery(); - const data = analyticsQuery.data as AnalyticsData | undefined; + // v1.4.39.2 — split the dashboard's analytics consumption so the + // tile-strip paints from the slim slice and the BD-Zielbereich + + // glucose tiles stream in from the thick slice afterwards. + // + // Pre-fix: a single `useAnalyticsQuery()` against `/api/analytics` + // (thick slice) blocked every per-type tile until the heavy fan-out + // resolved. Mood and medication tiles paint from their own dedicated + // endpoints and arrived first; the per-type measurement tiles then + // arrived as one burst once the full slice landed — Marc reported + // this "blocked-then-burst" pattern as "etwas nervig". + // + // Post-fix: two parallel queries. The slim slice (`?slice=summaries`) + // resolves the per-type DataSummary headlines + `lastSeenByType` and + // typically returns in well under a second once the rollup tier is + // warm; that paints every per-type tile in the strip. The thick + // slice resolves `bpInTargetPct*` + `glucoseByContext` for the + // BD-Zielbereich and glucose tiles, which stream in independently. + // Both queries share `caches.analytics` server-side so warm hits + // stay free, and TanStack's parallel-mounting keeps the network fan- + // out flat. + const analyticsSlimQuery = useAnalyticsQuery({ slice: "summaries" }); + const analyticsThickQuery = useAnalyticsQuery(); + const data = useMemo(() => { + const slim = analyticsSlimQuery.data; + const thick = analyticsThickQuery.data; + if (!slim && !thick) return undefined; + // Slim wins on overlapping fields when both have resolved — slim + // and thick compute `summaries` / `lastSeenByType` from the same + // SQL helpers so the values agree, and reading from whichever + // resolves first keeps the tile strip painting as soon as either + // query lands. + const summaries = + slim?.summaries ?? thick?.summaries ?? ({} as Record); + const lastSeenByType = + slim?.lastSeenByType ?? thick?.lastSeenByType; + return { + summaries, + lastSeenByType, + bpInTargetPct: (thick?.bpInTargetPct ?? null) as number | null, + bpInTargetPct7d: thick?.bpInTargetPct7d ?? null, + bpInTargetPct30d: thick?.bpInTargetPct30d ?? null, + bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null, + bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null, + bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null, + glucoseByContext: thick?.glucoseByContext as + | Record + | undefined, + }; + }, [analyticsSlimQuery.data, analyticsThickQuery.data]); const { data: layoutData } = useQuery({ queryKey: queryKeys.dashboardWidgets(),