diff --git a/.env.production.example b/.env.production.example index c5c87c0e6..a56dbf734 100644 --- a/.env.production.example +++ b/.env.production.example @@ -541,24 +541,6 @@ API_TOKEN_HMAC_KEY="" # FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000" -# ----------------------------------------------------------------------------- -# Dashboard snapshot -- BUILD-TIME toggle (default ON) -# ----------------------------------------------------------------------------- -# The dashboard hydrates every above-the-fold tile from the single -# `GET /api/dashboard/snapshot` cell so the whole strip shares one -# completion moment. This is the default and the desired behaviour. -# -# `NEXT_PUBLIC_*` vars are inlined into the client bundle at BUILD time, -# not read at runtime -- so this has NO effect on the prebuilt GHCR -# image and is intentionally absent from the `docker-compose.yml` -# runtime `environment:` whitelist. To fall back to the legacy -# four-cell path you must rebuild the image with the var set to "false": -# -# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG -# -# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false" - - # ----------------------------------------------------------------------------- # Client-IP resolution -- optional (required behind Cloudflare) # ----------------------------------------------------------------------------- diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 039ecfd43..7bd473110 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -195,26 +195,6 @@ bare Nginx — including the `X-Forwarded-For` chain shape that the rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how many hops to read back. -## Build-time feature toggles - -A small number of toggles are `NEXT_PUBLIC_*` variables: the framework -inlines them into the client bundle at **build time**, so the pre-built -GHCR image cannot read them at runtime. Setting them in `.env` or the -compose `environment:` block has no effect — they only take hold when -you build your own image. They are intentionally absent from the -compose runtime whitelist for that reason. - -| Variable | Default | Effect when set | -| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. | - -To flip one, rebuild the image with the variable exported: - -```bash -NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build -# or pass it as a Dockerfile build ARG / Docker build --build-arg -``` - ## 7. Where to go next | Next step | File | diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 0aa90fe5d..51d7ef289 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -21,12 +21,10 @@ test.describe("authenticated dashboard render", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { - // v1.7.2 — the dashboard snapshot flag is default-ON, so the page - // reads every tile from `/api/dashboard/snapshot` and ignores the - // legacy cells below. Mock the snapshot with the same populated - // four-metric set so the tile strip + charts paint. The legacy - // `/api/analytics` + `/api/mood/analytics` mocks stay for the - // reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path. + // The page reads every tile from `/api/dashboard/snapshot`; mock it + // with the same populated four-metric set so the tile strip + charts + // paint. The dashboard ignores the `/api/analytics` and + // `/api/mood/analytics` mocks below. await mockDashboardSnapshot(page); // Mock analytics with a populated weight summary so the weight tile diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 8e3ae7574..9f3fec2a2 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -88,20 +88,16 @@ function mockTargetBands(): DashboardTargetBands { /** * v1.7.2 — shared mock for `GET /api/dashboard/snapshot`. * - * The dashboard snapshot flag flipped default-ON in v1.7.2, so * `src/app/page.tsx` reads every above-the-fold tile from the single - * `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` - * is `"false"`. The e2e build sets no such var, so the snapshot path is - * live in CI. Specs that previously mocked only the legacy cells - * (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`) - * now hit the REAL snapshot route, which returns empty `summaries` for - * the seed user (zero measurements) and blanks the tile strip. + * `/api/dashboard/snapshot` cell. Without a mock a spec hits the REAL + * snapshot route, which returns empty `summaries` for the seed user (zero + * measurements) and blanks the tile strip. * * `mockDashboardSnapshot` registers a `page.route` for the snapshot - * endpoint returning a correctly-typed `DashboardSnapshot` so the - * default-ON path paints deterministic tiles. Callers keep their legacy - * mocks alongside it — harmless, and they protect the reversible - * `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path. + * endpoint returning a correctly-typed `DashboardSnapshot` so the tiles + * paint deterministically. Mocks of the older cells (`/api/analytics`, + * `/api/mood/analytics`, `/api/dashboard/widgets`) beside it are unused by + * the dashboard. */ /** Fill a `DataSummary` from a sparse partial, defaulting the rest. */ diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 66228b51e..097316b9d 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 336, - "total": 109385, + "total": 109383, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -38,7 +38,7 @@ "app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262, "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, - "app/auth/login/page.tsx :: LoginPage": 569, + "app/auth/login/page.tsx :: LoginPage": 567, "app/auth/register/page.tsx :: RegisterPage": 285, "app/coach/conversations/page.tsx :: CoachConversationsBody": 249, "app/coach/plans/page.tsx :: CoachPlansBody": 216, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 105c8dc14..8db3dc8c1 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2876, - "total": 9161, + "count": 2872, + "total": 9153, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -70,7 +70,6 @@ "__tests__/unit-preference-display-guard.test.ts": 2, "__tests__/v1391-additive-contract.test.ts": 1, "__tests__/write-rate-limit-guard.test.ts": 1, - "app/__tests__/dashboard-analytics-split.test.ts": 2, "app/__tests__/dashboard-chart-reveal.test.tsx": 2, "app/__tests__/dashboard-suspense-boundaries.test.ts": 2, "app/__tests__/dashboard-tile-strip-skeleton.test.ts": 4, @@ -1791,7 +1790,6 @@ "lib/analytics/__tests__/effective-range.test.ts": 1, "lib/analytics/__tests__/glucose-panel-coherence.test.ts": 1, "lib/analytics/__tests__/glucose-panel-window.test.ts": 2, - "lib/analytics/__tests__/merge-slim-thick.test.ts": 3, "lib/analytics/__tests__/mood-series.test.ts": 1, "lib/analytics/__tests__/range-delta.test.ts": 1, "lib/analytics/__tests__/resting-pulse.test.ts": 1, @@ -1824,7 +1822,6 @@ "lib/analytics/correlations.ts": 1, "lib/analytics/intraday-pulse-io.ts": 2, "lib/analytics/intraday-pulse.ts": 3, - "lib/analytics/merge-slim-thick.ts": 2, "lib/analytics/mood-series.ts": 2, "lib/analytics/range-delta.ts": 1, "lib/analytics/range-shared.ts": 1, @@ -1929,7 +1926,6 @@ "lib/dashboard/__tests__/meds-today.test.ts": 1, "lib/dashboard/__tests__/score-narrative.test.ts": 1, "lib/dashboard/__tests__/score-rings.test.ts": 1, - "lib/dashboard/__tests__/snapshot-flag.test.ts": 1, "lib/dashboard/__tests__/snapshot.test.ts": 15, "lib/dashboard/__tests__/target-bands.test.ts": 2, "lib/dashboard/bands.ts": 1, diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 89f9a534e..676aa37f2 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -136,10 +136,6 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly> = { "CI-only knob for the same nightly eval workflow", HEALTHLOG_MCP_TOKEN: "read by the local stdio MCP bridge the operator runs on their own machine; the production image strips tsx so it cannot run there", - - // --- A whitelist entry would be obeyed by half the readers. --- - NEXT_PUBLIC_DASHBOARD_SNAPSHOT: - "NEXT_PUBLIC_* is inlined into the client bundle at build time and three of its four readers are client components, so a runtime value would steer the server half only — a split-brain worse than no knob. .env.production.example already documents it as a build-time toggle and says it is deliberately absent from the whitelist; adding it would make that note false.", }; function sourceFiles(): string[] { diff --git a/src/app/__tests__/dashboard-analytics-split.test.ts b/src/app/__tests__/dashboard-analytics-split.test.ts deleted file mode 100644 index f895ff77f..000000000 --- a/src/app/__tests__/dashboard-analytics-split.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; - -/** - * 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 the maintainer 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 - * two call shapes. - * - * v1.7.0 — both calls now carry an `enabled` gate so the unified - * snapshot rollout flag can disable them in favour of a single - * `/api/dashboard/snapshot` cell. With the flag OFF (default) the - * slim/thick split still drives the page, so the protective intent - * holds; the matchers only relaxed to tolerate the `enabled` arg. - */ - -describe("v1.4.39.2 — dashboard analytics slim/thick split", () => { - it("mounts both the slim slice and the thick slice in parallel", () => { - const src = dashboardSource(); - // Slim slice — `?slice=summaries` paints the per-type tile strip. - expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/); - // Thick slice — the no-slice `useAnalyticsQuery` feeds the - // BD-Zielbereich + glucose tiles. Both must coexist. The thick - // call now opens with `{ enabled:` (no `slice` key). - expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*enabled:/); - }); - - it("merges slim and thick results so call-sites stay shape-stable", () => { - const src = dashboardSource(); - // 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/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index e9283bba1..0fd3f455a 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -284,12 +284,9 @@ describe("the tile the user actually sees", () => { describe("the gate reaches every feed, not just the default one", () => { it("strips the recovery-owned series from a summaries slice", () => { // `gateSummariesByModules` moved out of the snapshot builder, where it - // was private, so `/api/analytics` can apply the SAME filter. With - // `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` that route feeds the tiles, and - // it filtered nothing at all — so recovery-off plus RMSSD-only still - // painted the tile, on the exact path the helper's docblock promised it - // could not. A gate that only holds on one feed is only as strong as a - // rollout flag. + // was private, so `/api/analytics` can apply the SAME filter: that route + // filtered nothing at all, so recovery-off plus RMSSD-only still reached + // every reader of the slice. const summaries = { HEART_RATE_VARIABILITY: summary(9, 55), HRV_RMSSD: summary(30, 42), diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 0e20a0e51..10e8afef0 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; -import { readSource } from "@/__tests__/helpers/source-files"; /** * v1.4.40 — dashboard Suspense boundaries. @@ -57,19 +56,6 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { ); }); - it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => { - const src = readSource( - "src/components/dashboard/tiles/use-dashboard-data.ts", - "export function useDashboardData", - ); - // Module-scope declaration — search outside the hook body. Declared - // inside it, every render would create a fresh `{}` reference. - const moduleSliceEnd = src.indexOf("export function useDashboardData"); - expect(moduleSliceEnd).toBeGreaterThan(0); - const moduleSlice = src.slice(0, moduleSliceEnd); - expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/); - }); - it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { const src = dashboardSource(); // The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 5e3caab19..83549915a 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -24,11 +24,9 @@ import { * strip's footprint immediately and swaps to the real tiles the moment * the query resolves. * - * v1.7.0 — the gate now keys off `primaryLoading`, which resolves to - * the snapshot cell under `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true` and the - * slim analytics cell otherwise. A disabled TanStack query reports - * `isLoading: false`, so keying off the slim query in snapshot mode - * (where it is `enabled: false`) flashed the empty state for the whole + * v1.7.0 — the gate keys off `primaryLoading`, which resolves to the + * snapshot cell. A disabled TanStack query reports `isLoading: false`, so + * keying off any other query flashed the empty state for the whole * snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the * behaviour; textual pins keep the wiring honest. */ @@ -198,13 +196,13 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { expect(skeletonBlock?.[0]).toContain(" { + it("derives `primaryLoading` from the snapshot query", () => { const src = dashboardSource(); // v1.16.4 — `!mounted` pins SSR + the hydration render to the // skeleton branch so a late-hydrating boundary can't disagree with // the server HTML (React #418); see `useMounted`. expect(src).toMatch( - /const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*\(snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading\);/, + /const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*snapshotQuery\.isLoading;/, ); }); diff --git a/src/app/__tests__/prefetch-record-identity.test.tsx b/src/app/__tests__/prefetch-record-identity.test.tsx index d73cc01da..21d530a12 100644 --- a/src/app/__tests__/prefetch-record-identity.test.tsx +++ b/src/app/__tests__/prefetch-record-identity.test.tsx @@ -64,9 +64,6 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/dashboard/snapshot-read", () => ({ readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u), })); -vi.mock("@/lib/dashboard/snapshot-flag", () => ({ - isDashboardSnapshotEnabled: () => true, -})); vi.mock("@/lib/dashboard/batch-chart-types", () => ({ computeBatchWindow: () => ({ from: "2026-07-01T00:00:00.000Z", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 41dedd7a6..e04de5d20 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -80,11 +80,8 @@ export const GET = apiHandler(async (request?: Request) => { const slim = await readSummariesSlice(user.id, { serveStale: true }); // Drop the types whose module the user turned off, from the SAME map // the snapshot builder gates on (`SUMMARY_TYPE_MODULE`, via - // `gateSummariesByModules`). With `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` - // this slice — not the snapshot — feeds the dashboard tile strip, and - // it used to hand back every type regardless: recovery off plus an - // RMSSD-only account still painted the HRV tile, because nothing on - // this path had ever filtered. + // `gateSummariesByModules`), so a switched-off module's series never + // reaches a page that reads this slice. // // Gated AFTER the cache read on purpose. The cached cell stays // module-independent, so toggling a module takes effect on the next diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 49b44b786..aaa74ab3b 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -21,7 +21,6 @@ import { } from "@/lib/api/api-fetch"; import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; @@ -237,9 +236,7 @@ export default function LoginPage() { // ample headroom to land before the new snapshot is written. void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); - if (target === "/" && isDashboardSnapshotEnabled()) { - prefetchDashboardSnapshot(queryClient); - } + if (target === "/") prefetchDashboardSnapshot(queryClient); router.push(target); } diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index b162629dc..d8240b006 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -84,7 +84,6 @@ export default function DashboardPageClient({ const dashboard = useDashboardData({ isAuthenticated, mounted, - user, batchWindowProp: batchWindow, }); const { digestQuery, layout, primaryLoading } = dashboard; diff --git a/src/app/page.tsx b/src/app/page.tsx index bda704c49..047cb9f5f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,7 +6,6 @@ import { import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { computeBatchWindow, deriveBatchChartTypes, @@ -116,29 +115,18 @@ export default async function DashboardPage() { }); } - // Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives - // its chart-type list from analytics queries the RSC did not run, so there - // is no byte-identical key to dehydrate the series under. In legacy mode - // `batchWindow` stays undefined and the client falls back to its own - // browser-local window exactly as before — no behaviour change off-flag. - const snapshotMode = isDashboardSnapshotEnabled(); + // The profile-tz window is computed ONCE and threaded to the client so + // the batched-series key matches byte-for-byte. + batchWindow = computeBatchWindow(new Date(), user.timezone); - // The profile-tz window is computed ONCE (snapshot mode only) and threaded - // to the client so the batched-series key matches byte-for-byte. - if (snapshotMode) { - batchWindow = computeBatchWindow(new Date(), user.timezone); - } - - const seriesTypes = snapshotMode - ? deriveBatchChartTypes( - resolveDashboardLayout(wireBody.layout), - ( - wireBody.tiles as { - summaries?: Record; - } - )?.summaries, - ) - : []; + const seriesTypes = deriveBatchChartTypes( + resolveDashboardLayout(wireBody.layout), + ( + wireBody.tiles as { + summaries?: Record; + } + )?.summaries, + ); // Digest carries a soft budget (cold-day extras rebuild); series is a // warm rollup read (< 100 ms) with none. Both fail soft to `undefined`. diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts index c26154b33..e2a634e65 100644 --- a/src/components/dashboard/dashboard-gates.ts +++ b/src/components/dashboard/dashboard-gates.ts @@ -19,12 +19,11 @@ import type { /** * v1.7.0 — first-paint gate for the dashboard tile strip. * - * `primaryLoading` must come from whichever query actually drives the - * tiles: the snapshot cell by default, the slim analytics cell when - * `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports - * `isLoading: false` (idle fetch status), so keying off the wrong - * source flashes the empty state for the whole fetch. Pure + exported - * so the gate has direct unit coverage without mounting the page. + * `primaryLoading` comes from the snapshot cell that drives the tiles. A + * disabled TanStack query reports `isLoading: false` (idle fetch status), + * so keying off another source flashes the empty state for the whole + * fetch. Pure + exported so the gate has direct unit coverage without + * mounting the page. */ export function resolveDashboardFirstPaintGate(input: { trendCardCount: number; diff --git a/src/components/dashboard/tiles/tile-context.ts b/src/components/dashboard/tiles/tile-context.ts index 5838999f5..31a08fbd3 100644 --- a/src/components/dashboard/tiles/tile-context.ts +++ b/src/components/dashboard/tiles/tile-context.ts @@ -127,8 +127,7 @@ export function useDashboardTileContext({ ): number | null => value == null ? null : unitDisplay.toDisplayDelta(type, value); - const { data, layout, layoutData, moodData, snapshot, snapshotEnabled } = - dashboard; + const { data, layout, layoutData, moodData, snapshot } = dashboard; const summaries = data?.summaries; const compareBaseline = layout.comparisonBaseline ?? "none"; @@ -183,15 +182,15 @@ export function useDashboardTileContext({ const layoutResolved = layoutData != null; // Band and target numbers come resolved from the snapshot (`targetBands`). - // The client copy below is computed from the profile through the SAME + // The client copy below is computed from the profile (the age defaults; the + // person's own targets arrive with the snapshot) through the SAME // `buildDashboardBands` the server uses, so a still-loading snapshot frame - // keeps real bands rather than blank charts and the two cannot disagree. + // keeps real bands rather than blank charts. // The profile it reads is the caller's, and under a switch the caller is // the delegate: `viewerBandProfile` then draws no client band at all and // waits for the snapshot's, which resolve against the record. Keyed on the // `user` identity so the React Compiler can preserve the memo. const inSharedRecord = user?.accountAccess?.active != null; - const thresholdsData = dashboard.thresholdsData; const clientBandProfile = useMemo( () => viewerBandProfile( @@ -199,35 +198,32 @@ export function useDashboardTileContext({ dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, gender: toProfileSex(user?.gender), heightCm: user?.heightCm ?? null, - thresholdsJson: thresholdsData?.overrides ?? null, + thresholdsJson: null, }, inSharedRecord, ), - [user, thresholdsData, inSharedRecord], + [user, inSharedRecord], ); const clientBands = useMemo( () => buildDashboardBands(clientBandProfile), [clientBandProfile], ); - const serverBands = snapshotEnabled ? snapshot?.targetBands : undefined; - const bands = serverBands ?? clientBands; + const bands = snapshot?.targetBands ?? clientBands; // Which way a weight change counts as progress against the stored target. // The snapshot carries the server's answer; the fallback asks the same // resolver from the same profile the band fallback reads. const weight = summaries?.WEIGHT; const weightTrend = - (snapshotEnabled ? snapshot?.tiles.weightTrend : undefined) ?? + snapshot?.tiles.weightTrend ?? resolveWeightTrend( resolveWeightTargetOverride(clientBandProfile.thresholdsJson), weightTrendReferenceKg(weight), ); // Every other tile's direction comes resolved from the server against the - // record's own targets. The snapshot-disabled path has no resolved answer - // and renders neutral rather than guessing a polarity. - const tileDirections = snapshotEnabled - ? snapshot?.tiles.directions - : undefined; + // record's own targets; until the snapshot lands it renders neutral rather + // than guessing a polarity. + const tileDirections = snapshot?.tiles.directions; const directionOf = (key: string): TrendDirectionSentiment => tileDirections?.[key]?.direction ?? "neutral"; diff --git a/src/components/dashboard/tiles/use-dashboard-data.ts b/src/components/dashboard/tiles/use-dashboard-data.ts index 21bbd3ef7..9b0afaa28 100644 --- a/src/components/dashboard/tiles/use-dashboard-data.ts +++ b/src/components/dashboard/tiles/use-dashboard-data.ts @@ -3,42 +3,19 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { AuthUser } from "@/hooks/use-auth"; -import { useSurfaceVisible } from "@/hooks/use-surface-visible"; import { apiGet } from "@/lib/api/api-fetch"; -import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import type { DataSummary } from "@/lib/analytics/trends"; import { computeLocalBatchWindow, deriveBatchChartTypes, type BatchWindow, } from "@/lib/dashboard/batch-chart-types"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; -import { hideModuleWidgets } from "@/lib/dashboard/widget-modules"; -import { - resolveDashboardLayout, - type DashboardLayout, -} from "@/lib/dashboard-layout"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { resolveDashboardLayout } from "@/lib/dashboard-layout"; import { useDailyDigest } from "@/lib/queries/use-daily-digest"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { queryKeys } from "@/lib/query-keys"; import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics"; -/** - * Module scope so the option object is a stable reference across renders. - * - * Refetch on window focus: the dashboard is the surface a person leaves open - * in a background tab while changing its tile selection elsewhere; without it - * the stale layout sat visible for up to the poll interval ("saved but nothing - * changed"). `staleTime` bounds the cost to one refetch a minute, and the - * server side is SWR-cached, so a focus flick is a cheap cache hit. - */ -const DASHBOARD_QUERY_OPTS = { - staleTime: 60_000, - refetchOnWindowFocus: true, -} as const; - /** One day of the batched series the dashboard charts read their slice from. */ export interface BatchedSeriesRow { value: number; @@ -54,55 +31,29 @@ export interface DashboardMoodData { } /** - * The tiles' `AnalyticsData` from whichever path feeds them. Snapshot path: - * the one cell, where `extras` is null on a rollup-coverage miss (two-phase - * contract), so the BP-in-target and glucose fields stay undefined and those - * tiles shimmer while the rest of the strip paints. Legacy path: the two - * analytics slices merged. + * The tiles' `AnalyticsData` from the snapshot. `extras` is null on a + * rollup-coverage miss (two-phase contract), so the BP-in-target and glucose + * fields stay undefined and those tiles shimmer while the rest of the strip + * paints. */ function assembleDashboardAnalytics( - snapshotEnabled: boolean, - snapshot: ReturnType["data"], - slim: Parameters[0], - thick: Parameters[1], + snap: ReturnType["data"], ): AnalyticsData | undefined { - if (snapshotEnabled) { - const snap = snapshot; - if (!snap) return undefined; - return { - summaries: snap.tiles.summaries, - lastSeenByType: snap.tiles.lastSeenByType, - // Optional on the snapshot (additive contract). - sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null, - bpInTargetPct: snap.extras?.bpInTargetPct ?? null, - bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null, - bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null, - bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null, - bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null, - bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null, - bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null, - bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null, - glucoseByContext: snap.extras?.glucoseByContext as - Record | undefined, - }; - } - // `mergeSlimAndThickAnalytics` falls back to the thick slice when the - // slim one resolves with no content, and otherwise lets slim paint first. - const merged = mergeSlimAndThickAnalytics(slim, thick); - if (!merged) return undefined; + if (!snap) return undefined; return { - summaries: merged.summaries, - lastSeenByType: merged.lastSeenByType, - sleepSourceDiscrepancy: merged.sleepSourceDiscrepancy, - bpInTargetPct: merged.bpInTargetPct, - bpInTargetPct7d: merged.bpInTargetPct7d, - bpInTargetPct30d: merged.bpInTargetPct30d, - bpInTargetPctAllTime: merged.bpInTargetPctAllTime, - bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth, - bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear, - bpInTargetCount90: merged.bpInTargetCount90, - bpInTargetSpanDays90: merged.bpInTargetSpanDays90, - glucoseByContext: merged.glucoseByContext as + summaries: snap.tiles.summaries, + lastSeenByType: snap.tiles.lastSeenByType, + // Optional on the snapshot (additive contract). + sleepSourceDiscrepancy: snap.tiles.sleepSourceDiscrepancy ?? null, + bpInTargetPct: snap.extras?.bpInTargetPct ?? null, + bpInTargetPct7d: snap.extras?.bpInTargetPct7d ?? null, + bpInTargetPct30d: snap.extras?.bpInTargetPct30d ?? null, + bpInTargetPctAllTime: snap.extras?.bpInTargetPctAllTime ?? null, + bpInTargetPctPriorMonth: snap.extras?.bpInTargetPctPriorMonth ?? null, + bpInTargetPctPriorYear: snap.extras?.bpInTargetPctPriorYear ?? null, + bpInTargetCount90: snap.extras?.bpInTargetCount90 ?? null, + bpInTargetSpanDays90: snap.extras?.bpInTargetSpanDays90 ?? null, + glucoseByContext: snap.extras?.glucoseByContext as Record | undefined, }; } @@ -112,10 +63,7 @@ function assembleDashboardAnalytics( * * The tiles hydrate from ONE un-gated `/api/dashboard/snapshot` cell, so the * whole strip shares one completion moment and `/api/auth/me` leaves the cold - * critical path. `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` falls back to the - * legacy independent cells (two analytics slices, the widget layout, the mood - * analytics and the user's own thresholds); either way the same - * `AnalyticsData` shape comes out, so no tile knows which path fed it. + * critical path. * * The Today hero's digest is read here too, in its original place in the read * order: it is the day's data, so no module owns it, and it is gated on auth @@ -124,99 +72,34 @@ function assembleDashboardAnalytics( export function useDashboardData({ isAuthenticated, mounted, - user, batchWindowProp, }: { isAuthenticated: boolean; mounted: boolean; - user: AuthUser | null; /** * The batched-series window the RSC prefetch computed from the profile * time zone. Adopted verbatim so the `chartSeriesBatch` key matches the key - * the server dehydrated under; absent on the prefetch-off and legacy paths, - * where the browser-local window stands in. + * the server dehydrated under; absent when the prefetch is off, where the + * browser-local window stands in. */ batchWindowProp?: BatchWindow; }) { - const snapshotEnabled = isDashboardSnapshotEnabled(); - const snapshotQuery = useDashboardSnapshot(snapshotEnabled); + const snapshotQuery = useDashboardSnapshot(); const digestQuery = useDailyDigest(isAuthenticated); - - // Legacy path: the slim slice (`?slice=summaries`) paints every per-type - // tile; the thick slice streams the BP-in-target and glucose fields in - // afterwards. Both share `caches.analytics` server-side. - const analyticsSlimQuery = useAnalyticsQuery({ - slice: "summaries", - enabled: !snapshotEnabled && isAuthenticated, - }); - const analyticsThickQuery = useAnalyticsQuery({ - enabled: !snapshotEnabled && isAuthenticated, - }); const data = useMemo( - () => - assembleDashboardAnalytics( - snapshotEnabled, - snapshotQuery.data, - analyticsSlimQuery.data, - analyticsThickQuery.data, - ), - [ - snapshotEnabled, - snapshotQuery.data, - analyticsSlimQuery.data, - analyticsThickQuery.data, - ], + () => assembleDashboardAnalytics(snapshotQuery.data), + [snapshotQuery.data], ); - - // The user's own targets, for the legacy (snapshot-disabled) band fallback - // only. In snapshot mode the server resolves the same overrides into - // `targetBands` and this query never fires. - const { data: thresholdsData } = useQuery({ - queryKey: queryKeys.userThresholds(), - queryFn: async () => { - return apiGet<{ - overrides: Record; - }>("/api/user/thresholds"); - }, - enabled: !snapshotEnabled && isAuthenticated, - ...DASHBOARD_QUERY_OPTS, - }); - - const { data: layoutDataLegacy } = useQuery({ - queryKey: queryKeys.dashboardWidgets(), - queryFn: async () => { - return apiGet("/api/dashboard/widgets"); - }, - enabled: !snapshotEnabled && isAuthenticated, - ...DASHBOARD_QUERY_OPTS, - }); // The snapshot publishes its layout with switched-off modules' widgets - // already hidden. The legacy feed is the stored layout as saved (Settings - // edits that exact value), so the same mask is applied here, from the same - // surface map, before anything paints. - const layoutData = snapshotEnabled - ? snapshotQuery.data?.layout - : layoutDataLegacy && hideModuleWidgets(layoutDataLegacy, user?.modules); - const moodWidgetVisible = useSurfaceVisible("widget:mood"); - - const { data: moodDataLegacy } = useQuery({ - queryKey: queryKeys.moodAnalytics(), - queryFn: async () => { - return apiGet("/api/mood/analytics"); - }, - // The route refuses with the mood module off; do not ask. - enabled: !snapshotEnabled && isAuthenticated && moodWidgetVisible, - ...DASHBOARD_QUERY_OPTS, - }); - const moodData: DashboardMoodData | undefined = snapshotEnabled - ? snapshotQuery.data - ? { - entries: snapshotQuery.data.tiles.mood.entries, - summary: (snapshotQuery.data.tiles.mood.summary ?? - undefined) as DataSummary, - } - : undefined - : moodDataLegacy; + // already hidden. + const layoutData = snapshotQuery.data?.layout; + const moodData: DashboardMoodData | undefined = snapshotQuery.data + ? { + entries: snapshotQuery.data.tiles.mood.entries, + summary: (snapshotQuery.data.tiles.mood.summary ?? + undefined) as DataSummary, + } + : undefined; // Visibility and order of every widget. const layout = resolveDashboardLayout(layoutData); @@ -259,24 +142,17 @@ export function useDashboardData({ }, }); - // The loading flag follows whichever query actually drives the tiles: in - // snapshot mode the slim query is disabled and reports `isLoading: false` - // forever, which would select the empty state for the whole snapshot - // fetch. `!mounted` pins the SSR pass and the hydration render to the - // skeleton branch (server-side the data queries are disabled, and a late - // hydrating boundary would otherwise render the skeleton where the server - // rendered the empty state: React #418). The hero band and the strip share - // this one gate so both swap to content in the same render pass. - const primaryLoading = - !mounted || - (snapshotEnabled ? snapshotQuery.isLoading : analyticsSlimQuery.isLoading); + // `!mounted` pins the SSR pass and the hydration render to the skeleton + // branch (server-side the data queries are disabled, and a late hydrating + // boundary would otherwise render the skeleton where the server rendered + // the empty state: React #418). The hero band and the strip share this one + // gate so both swap to content in the same render pass. + const primaryLoading = !mounted || snapshotQuery.isLoading; return { - snapshotEnabled, snapshot: snapshotQuery.data, digestQuery, data, - thresholdsData, layoutData, layout, moodData, diff --git a/src/components/providers.tsx b/src/components/providers.tsx index de8dbec17..9630ba408 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -25,7 +25,6 @@ import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return"; import { LocaleServerSyncBridge } from "@/components/layout/locale-server-sync-bridge"; import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth"; import { useRecordSessionTransition } from "@/hooks/use-record-session-transition"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { discardDashboardSnapshotPreload, prefetchDashboardSnapshot, @@ -240,7 +239,7 @@ function DashboardSnapshotPreloader() { useEffect(() => { if (!ready) return; const controller = new AbortController(); - if (pathname === "/" && isDashboardSnapshotEnabled()) { + if (pathname === "/") { prefetchDashboardSnapshot(queryClient, controller.signal); } // v1.16.7 — same waterfall cut for the medications page: its list diff --git a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts index 954f80df2..da95e069c 100644 --- a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts +++ b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts @@ -11,13 +11,12 @@ import { queryKeys } from "@/lib/query-keys"; * very next SAME-TAB navigation — not "eventually" via the 120 s poll or a * window-focus flick. * - * The web dashboard (`src/app/page-client.tsx`) reads tile visibility/order - * from TWO cache cells: + * Tile visibility/order lives in TWO cache cells: * 1. the snapshot cell (`queryKeys.dashboardSnapshot(locale)` via - * `useDashboardSnapshot`) — `snapshotQuery.data.layout` is the layout - * source whenever snapshot mode is on (the default), and - * 2. the legacy `queryKeys.dashboardWidgets()` cell (snapshot mode off, plus - * the per-chart overlay-prefs hook). + * `useDashboardSnapshot`) — `snapshotQuery.data.layout` is what the web + * dashboard (`src/app/page-client.tsx`) paints from, and + * 2. the `queryKeys.dashboardWidgets()` cell (the settings editor and the + * per-chart overlay-prefs hook). * The settings save writes (2) directly via `setQueryData` with the server * response, so its freshness is unconditional. (1) is only INVALIDATED — and * that is where the same-tab staleness lived: @@ -154,12 +153,9 @@ describe("dashboard layout save — every layout source is covered (source pins) ); }); - it("the dashboard reads layout from the snapshot (or the dashboardWidgets cell) only", () => { + it("the dashboard reads layout from the snapshot only", () => { const pageSrc = dashboardSource(); - // Snapshot path: layout comes off the snapshot payload… expect(pageSrc).toContain("snapshotQuery.data?.layout"); - // …legacy path: the dashboardWidgets factory key (no bare literal). - expect(pageSrc).toContain("queryKeys.dashboardWidgets()"); // The analytics slices carry tile DATA (summaries), never layout — if // someone threads layout through them, this pin forces a rethink of the // save-path invalidation set. diff --git a/src/lib/analytics/__tests__/merge-slim-thick.test.ts b/src/lib/analytics/__tests__/merge-slim-thick.test.ts deleted file mode 100644 index 3f104c015..000000000 --- a/src/lib/analytics/__tests__/merge-slim-thick.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; -import type { DataSummary } from "@/lib/analytics/trends"; - -/** - * v1.4.39.3 — dashboard slim/thick merge robustness. - * - * v1.4.39.2 split the dashboard's analytics consumption into a slim - * `?slice=summaries` query and the thick default query. The original - * inline merge used `slim?.summaries ?? thick?.summaries` which - * short-circuited on a truthy-but-empty `{}` from the slim slice and - * blanked the tile strip even when thick carried the full payload — - * the regression the maintainer's v1.4.39.3 e2e CI flagged across eight - * dashboard / chart specs (chart-overlay-controls, dashboard, - * measurement-flow, charts-mobile). The pure helper extracted in - * v1.4.39.3 uses object emptiness as the discriminator so a populated - * thick payload survives an empty slim resolve. - */ - -const stubSummary = (): DataSummary => ({ - count: 30, - latest: 78.5, - min: 76, - max: 80, - mean: 78, - median: 78, - avg7: 78.2, - avg30: 77.9, - slope7: null, - slope30: { slope: -0.05, direction: "down", confidence: 0.5 }, - anomalyCount: 0, -}); - -describe("mergeSlimAndThickAnalytics", () => { - it("returns undefined when neither slim nor thick has resolved", () => { - expect(mergeSlimAndThickAnalytics(undefined, undefined)).toBeUndefined(); - }); - - it("prefers slim summaries when slim carries content (progressive paint)", () => { - const slim = { - summaries: { - WEIGHT: stubSummary(), - }, - }; - const thick = { - summaries: { - WEIGHT: { ...stubSummary(), latest: 1 }, - PULSE: stubSummary(), - }, - bpInTargetPct: 78, - }; - const merged = mergeSlimAndThickAnalytics(slim, thick); - // Slim wins on overlapping field — the v1.4.39.2 progressive-paint - // contract is preserved. - expect(merged?.summaries.WEIGHT?.latest).toBe(78.5); - // Slim does not carry PULSE; merge keeps slim's record as-is - // (matches the "as soon as slim lands, paint" semantic). - expect(merged?.summaries.PULSE).toBeUndefined(); - // Thick still wires the BD-Zielbereich + glucose fields. - expect(merged?.bpInTargetPct).toBe(78); - }); - - it("falls back to thick when slim resolves with an empty summaries record", () => { - // The v1.4.39.3 regression: empty slim `{}` is truthy by JS - // semantics so the old `??` short-circuited on it, blanking the - // tile strip even when thick had data. - const slim = { summaries: {} as Record }; - const thick = { - summaries: { - WEIGHT: stubSummary(), - }, - bpInTargetPct: 78, - }; - const merged = mergeSlimAndThickAnalytics(slim, thick); - expect(merged?.summaries.WEIGHT?.latest).toBe(78.5); - expect(merged?.bpInTargetPct).toBe(78); - }); - - it("falls back to thick when slim has not resolved yet", () => { - const thick = { - summaries: { - WEIGHT: stubSummary(), - }, - bpInTargetPct: 78, - }; - const merged = mergeSlimAndThickAnalytics(undefined, thick); - expect(merged?.summaries.WEIGHT?.latest).toBe(78.5); - expect(merged?.bpInTargetPct).toBe(78); - }); - - it("returns slim summaries when only slim has resolved (thick still loading)", () => { - const slim = { - summaries: { WEIGHT: stubSummary() }, - }; - const merged = mergeSlimAndThickAnalytics(slim, undefined); - expect(merged?.summaries.WEIGHT?.latest).toBe(78.5); - // Thick-only fields collapse to null/undefined so consumers stay - // undefined-safe — the BD-Zielbereich tile self-gates on these. - expect(merged?.bpInTargetPct).toBeNull(); - expect(merged?.glucoseByContext).toBeUndefined(); - }); - - it("returns empty summaries when both sides resolve empty (legit zero-data tenant)", () => { - const merged = mergeSlimAndThickAnalytics( - { summaries: {} }, - { summaries: {} }, - ); - expect(merged?.summaries).toEqual({}); - expect(merged?.bpInTargetPct).toBeNull(); - }); - - it("falls back to thick lastSeenByType when slim has none", () => { - const slim = { summaries: { WEIGHT: stubSummary() } }; - const thick = { - summaries: { WEIGHT: stubSummary() }, - lastSeenByType: { - WEIGHT: { lastSeenAt: "2026-05-01T00:00:00.000Z", daysAgo: 3 }, - }, - }; - const merged = mergeSlimAndThickAnalytics(slim, thick); - expect(merged?.lastSeenByType?.WEIGHT?.daysAgo).toBe(3); - }); - - it("prefers slim lastSeenByType when slim carries one", () => { - const slim = { - summaries: { WEIGHT: stubSummary() }, - lastSeenByType: { - WEIGHT: { lastSeenAt: "2026-05-10T00:00:00.000Z", daysAgo: 1 }, - }, - }; - const thick = { - summaries: { WEIGHT: stubSummary() }, - lastSeenByType: { - WEIGHT: { lastSeenAt: "2026-05-01T00:00:00.000Z", daysAgo: 10 }, - }, - }; - const merged = mergeSlimAndThickAnalytics(slim, thick); - expect(merged?.lastSeenByType?.WEIGHT?.daysAgo).toBe(1); - }); -}); diff --git a/src/lib/analytics/merge-slim-thick.ts b/src/lib/analytics/merge-slim-thick.ts deleted file mode 100644 index a529f27c5..000000000 --- a/src/lib/analytics/merge-slim-thick.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * v1.4.39.3 — pure helper that merges the dashboard's slim - * (`/api/analytics?slice=summaries`) and thick (`/api/analytics`) - * payloads into the single shape the dashboard tile-strip + chart row - * consumes. - * - * Background — v1.4.39.2 split `useAnalyticsQuery` into two parallel - * mounts so the per-type tile strip paints as soon as the slim slice - * lands and the BD-Zielbereich + glucose tiles stream in from the - * thick slice afterwards. The inline `useMemo` that performed the - * merge originally used `slim?.summaries ?? thick?.summaries` — - * structurally correct on production where both routes agree, but - * silently buggy when slim resolves first with an *empty* `{}` (e.g. - * a freshly-seeded tenant whose rollup tier hasn't folded yet, or a - * test environment that mocks thick but not slim). The empty object - * is truthy by JS semantics so `??` short-circuited and the tile - * strip painted blank even though thick carried the full payload — - * exactly the regression the v1.4.39.3 e2e CI surfaced across eight - * dashboard / chart specs. - * - * Post-fix: object emptiness is the discriminator. A non-empty slim - * payload still wins on overlapping fields (the v1.4.39.2 progressive- - * paint contract). An empty slim falls through to thick. When both - * are empty, the caller receives an empty record and the dashboard's - * data-floor gates render the appropriate empty state. - * - * Kept as a pure helper so the regression is verified with a tight - * unit test (`merge-slim-thick.test.ts`) rather than a source-text - * snapshot — string matching against `src/app/page.tsx` would not - * have caught the empty-object short-circuit bug. - */ -import type { DataSummary } from "@/lib/analytics/trends"; -import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; - -export interface AnalyticsSlimLike { - summaries?: Record | null; - lastSeenByType?: Record< - string, - { lastSeenAt: string; daysAgo: number } | null - > | null; - /** - * v1.28.x — latest-night source-discrepancy annotation, carried by - * both slices (the slim post-pass computes it; the thick body passes - * it through). Additive and null-safe. - */ - sleepSourceDiscrepancy?: SleepSourceDiscrepancy | null; -} - -export interface AnalyticsThickLike extends AnalyticsSlimLike { - bpInTargetPct?: number | null; - bpInTargetPct7d?: number | null; - bpInTargetPct30d?: number | null; - bpInTargetPctAllTime?: number | null; - bpInTargetPctPriorMonth?: number | null; - bpInTargetPctPriorYear?: number | null; - bpInTargetCount90?: number | null; - bpInTargetSpanDays90?: number | null; - glucoseByContext?: Record | null; -} - -export interface MergedDashboardAnalytics { - summaries: Record; - lastSeenByType?: - Record | undefined; - bpInTargetPct: number | null; - bpInTargetPct7d: number | null; - bpInTargetPct30d: number | null; - bpInTargetPctAllTime: number | null; - bpInTargetPctPriorMonth: number | null; - bpInTargetPctPriorYear: number | null; - bpInTargetCount90: number | null; - bpInTargetSpanDays90: number | null; - glucoseByContext: Record | undefined; - sleepSourceDiscrepancy: SleepSourceDiscrepancy | null; -} - -function hasContent( - record: Record | null | undefined, -): boolean { - return record != null && Object.keys(record).length > 0; -} - -/** - * Merge a slim and a thick analytics payload into the shape the - * dashboard renders against. Either input may be `undefined` - * (TanStack query unresolved) or carry an empty record (zero-data - * tenant); the helper falls back to whichever side has content - * without ever returning a payload that ignores a populated source. - * - * Returns `undefined` when neither input has resolved yet so the - * caller can keep its loading-state branch unchanged. - */ -export function mergeSlimAndThickAnalytics( - slim: AnalyticsSlimLike | undefined, - thick: AnalyticsThickLike | undefined, -): MergedDashboardAnalytics | undefined { - if (!slim && !thick) return undefined; - - const slimSummariesHaveContent = hasContent(slim?.summaries ?? null); - const summaries: Record = slimSummariesHaveContent - ? (slim!.summaries as Record) - : ((thick?.summaries as Record | null | undefined) ?? - (slim?.summaries as Record | null | undefined) ?? - {}); - - const slimFreshnessHasContent = hasContent(slim?.lastSeenByType ?? null); - const lastSeenByType: - Record | undefined = - slimFreshnessHasContent - ? (slim!.lastSeenByType as Record< - string, - { lastSeenAt: string; daysAgo: number } | null - >) - : (thick?.lastSeenByType ?? undefined); - - return { - summaries, - lastSeenByType, - bpInTargetPct: thick?.bpInTargetPct ?? null, - bpInTargetPct7d: thick?.bpInTargetPct7d ?? null, - bpInTargetPct30d: thick?.bpInTargetPct30d ?? null, - bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null, - bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null, - bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null, - bpInTargetCount90: thick?.bpInTargetCount90 ?? null, - bpInTargetSpanDays90: thick?.bpInTargetSpanDays90 ?? null, - glucoseByContext: thick?.glucoseByContext ?? undefined, - // Rides the same discriminator as `summaries`: whichever slice - // supplied the summaries also supplies the sleep annotation, so the - // marker can never point at a night the rendered headline doesn't - // show. `?? null` tolerates older cached payloads without the field. - sleepSourceDiscrepancy: - (slimSummariesHaveContent - ? slim!.sleepSourceDiscrepancy - : thick?.sleepSourceDiscrepancy) ?? null, - }; -} diff --git a/src/lib/dashboard/__tests__/snapshot-flag.test.ts b/src/lib/dashboard/__tests__/snapshot-flag.test.ts deleted file mode 100644 index 3ec9354e9..000000000 --- a/src/lib/dashboard/__tests__/snapshot-flag.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * v1.7.2 — unit tests for `isDashboardSnapshotEnabled`. - * - * The flag flipped default-ON in v1.7.2: the dashboard reads the unified - * `/api/dashboard/snapshot` cell UNLESS `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` - * is exactly `"false"`. Pin the boundary so a future edit can't quietly - * regress to the old `=== "true"` opt-in semantics. - */ -import { describe, it, expect, afterEach } from "vitest"; - -import { isDashboardSnapshotEnabled } from "../snapshot-flag"; - -describe("isDashboardSnapshotEnabled", () => { - const original = process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT; - - afterEach(() => { - if (original === undefined) { - delete process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT; - } else { - process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = original; - } - }); - - it("defaults ON when the var is unset", () => { - delete process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT; - expect(isDashboardSnapshotEnabled()).toBe(true); - }); - - it("stays ON for an empty string", () => { - process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = ""; - expect(isDashboardSnapshotEnabled()).toBe(true); - }); - - it('turns OFF only for the exact string "false"', () => { - process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = "false"; - expect(isDashboardSnapshotEnabled()).toBe(false); - }); - - it('stays ON for "true"', () => { - process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT = "true"; - expect(isDashboardSnapshotEnabled()).toBe(true); - }); -}); diff --git a/src/lib/dashboard/snapshot-flag.ts b/src/lib/dashboard/snapshot-flag.ts deleted file mode 100644 index 019db330f..000000000 --- a/src/lib/dashboard/snapshot-flag.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Reversible rollout flag for the unified dashboard snapshot - * consumption. - * - * Controls whether the dashboard page reads every above-the-fold tile - * from the single `GET /api/dashboard/snapshot` cell (default) or falls - * back to the legacy four independent cells (slim analytics + thick - * analytics + mood + widget layout). The snapshot path is the desired - * behaviour: one un-gated request hydrates the whole strip so every - * tile shares one completion moment, instead of four parallel cells - * where the mood cache resolves fastest and pops in ahead of the rest. - * - * Default ON. Set `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back - * to the legacy multi-cell path. Defaulting on (rather than gating on - * `=== "true"`) avoids the `NEXT_PUBLIC_*` build-time-baking trap: the - * snapshot path is exercised in dev / e2e without a special build - * flag, and the production image gets it without a Dockerfile ARG. - */ -export function isDashboardSnapshotEnabled(): boolean { - return process.env.NEXT_PUBLIC_DASHBOARD_SNAPSHOT !== "false"; -} diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index 4142347e7..ca9cd34f2 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -99,13 +99,11 @@ export function disabledSummaryTypes( /** * Strip disabled-module types from a summaries slice. Lifted out of - * `@/lib/dashboard/snapshot`, where it was private, once a second - * dashboard feed needed it: with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` - * the tile strip hydrates from `/api/analytics` instead of the snapshot, - * and that route filtered nothing — so a module the user had turned off - * still reached the client, and the tile it owns still painted. The gate - * has to sit on every feed that carries summaries, not just the default - * one, or it is only as strong as a rollout flag. + * `@/lib/dashboard/snapshot`, where it was private, once `/api/analytics` + * needed it too: that route filtered nothing, so a module the user had + * turned off still reached the client. The gate has to sit on every feed + * that carries summaries (the snapshot, the analytics slice, the Insights + * presence read), not just one. * * Pure over its inputs and browser-safe (string keys + the map above), so * the snapshot builder, the analytics route and any future feed share one