chore: remove the dashboard's pre-snapshot feed

NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false built a dashboard that read its
tiles from two analytics slices, the widget layout, the mood analytics
and the thresholds instead of the one snapshot cell. It took a rebuilt
image to reach, and the snapshot has been the only path the published
image runs since it became the default. The flag, its readers, the
legacy reads in the dashboard hook and tile context, the slim and thick
merge helper, the build-time toggle notes in .env.production.example and
the self-hosting guide, and the tests that pinned the legacy wiring go.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:20:16 +01:00
1 parent d99456138b
commit 9dc519dd85
26 files changed
+106 -729

No files matched your search

-18
View File
@@ -541,24 +541,6 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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)
# -----------------------------------------------------------------------------
-20
View File
@@ -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 |
+4 -6
View File
@@ -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
+7 -11
View File
@@ -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. */
@@ -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,
@@ -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,
@@ -136,10 +136,6 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
"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[] {
@@ -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/);
});
});
@@ -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),
@@ -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
@@ -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("<ChartSkeleton");
});
it("derives `primaryLoading` from the snapshot vs slim query", () => {
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;/,
);
});
@@ -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",
+2 -5
View File
@@ -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
+1 -4
View File
@@ -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);
}
-1
View File
@@ -84,7 +84,6 @@ export default function DashboardPageClient({
const dashboard = useDashboardData({
isAuthenticated,
mounted,
user,
batchWindowProp: batchWindow,
});
const { digestQuery, layout, primaryLoading } = dashboard;
+11 -23
View File
@@ -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<string, { count?: number }>;
}
)?.summaries,
)
: [];
const seriesTypes = deriveBatchChartTypes(
resolveDashboardLayout(wireBody.layout),
(
wireBody.tiles as {
summaries?: Record<string, { count?: number }>;
}
)?.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`.
+5 -6
View File
@@ -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;
+11 -15
View File
@@ -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";
@@ -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<typeof useDashboardSnapshot>["data"],
slim: Parameters<typeof mergeSlimAndThickAnalytics>[0],
thick: Parameters<typeof mergeSlimAndThickAnalytics>[1],
snap: ReturnType<typeof useDashboardSnapshot>["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<string, DataSummary> | 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<string, DataSummary> | 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<string, { min: number; max: number }>;
}>("/api/user/thresholds");
},
enabled: !snapshotEnabled && isAuthenticated,
...DASHBOARD_QUERY_OPTS,
});
const { data: layoutDataLegacy } = useQuery({
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
return apiGet<DashboardLayout>("/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<DashboardMoodData>("/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,
+1 -2
View File
@@ -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
@@ -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.
@@ -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<string, DataSummary> };
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);
});
});
-137
View File
@@ -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<string, DataSummary> | 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<string, unknown> | null;
}
export interface MergedDashboardAnalytics {
summaries: Record<string, DataSummary>;
lastSeenByType?:
Record<string, { lastSeenAt: string; daysAgo: number } | null> | 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<string, unknown> | undefined;
sleepSourceDiscrepancy: SleepSourceDiscrepancy | null;
}
function hasContent(
record: Record<string, unknown> | 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<string, DataSummary> = slimSummariesHaveContent
? (slim!.summaries as Record<string, DataSummary>)
: ((thick?.summaries as Record<string, DataSummary> | null | undefined) ??
(slim?.summaries as Record<string, DataSummary> | null | undefined) ??
{});
const slimFreshnessHasContent = hasContent(slim?.lastSeenByType ?? null);
const lastSeenByType:
Record<string, { lastSeenAt: string; daysAgo: number } | null> | 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,
};
}
@@ -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);
});
});
-21
View File
@@ -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";
}
+5 -7
View File
@@ -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