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(),