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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 09:55:15 +02:00
1 parent f8a1319e94
commit 3126e4cff2
2 files changed
+102 -9

No files matched your search

@@ -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/);
});
});
+50 -9
View File
@@ -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<AnalyticsData | undefined>(() => {
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<string, DataSummary>);
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<string, DataSummaryType>
| undefined,
};
}, [analyticsSlimQuery.data, analyticsThickQuery.data]);
const { data: layoutData } = useQuery({
queryKey: queryKeys.dashboardWidgets(),