diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5fcafb1..cf45a2c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,74 @@ # Changelog +## [1.4.39.3] — 2026-05-21 — Dashboard slim/thick merge robustness and list-page value precision + +The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across two +viewport profiles (chart-overlay-controls, dashboard, charts-mobile, +measurement-flow) that were all downstream of two narrow regressions +introduced — or, in one case, surfaced — by the v1.4.39.2 dashboard +split. v1.4.39.3 fixes the root causes, hardens the merge contract with +a pure helper + unit coverage, and brings the e2e route-mock patterns +in line with the v1.4.37 W-CI lesson so the regression class cannot +recur. + +### Fixed + +- **Dashboard slim/thick merge no longer blanks the tile strip when + the slim slice resolves empty.** The v1.4.39.2 inline merge used + `slim?.summaries ?? thick?.summaries`. JavaScript treats a populated + `{}` as truthy, so a zero-data slim resolve short-circuited the + thick fallback even when thick carried the full per-type payload — + the tile strip painted blank, the chart row never mounted, and the + range tabs / overlay-controls trigger downstream of the strip + cascaded into eight test failures. The merge now extracts to + `mergeSlimAndThickAnalytics` (`src/lib/analytics/merge-slim-thick.ts`) + which uses object emptiness as the discriminator: a populated slim + still wins on overlapping fields (the v1.4.39.2 progressive-paint + contract is preserved), an empty slim falls through to thick, and + both empty leaves the dashboard's data-floor gates to render the + appropriate empty state. +- **`MeasurementList` no longer truncates non-grouped readings to + integers.** The v1.4.37 W7c collapsed-list view passed every value + through `fmt.integer`, which kept the per-day step / activity + aggregates correct but silently truncated single weight / body-fat / + body-temperature readings — 78.4 kg rendered as "78 kg" on both the + desktop table and the mobile card. Grouped rows (`isGrouped === true`, + the daily aggregate row for cumulative HK types) keep `fmt.integer` + because the underlying readings are integer-only by definition; + non-grouped rows now use `fmt.number` which honours up to three + fraction digits without forcing a minimum (so 78 stays "78" and + 78.4 stays "78.4" / "78,4" depending on locale). + +### Changed + +- **Playwright route mocks for `/api/analytics` migrate to the regex + pattern across every authenticated spec** (`dashboard`, + `chart-overlay-controls`, `measurement-flow`, `charts-mobile`, + `a11y`, `v1427-responsive-sheet`, `v1427-coach-launch`, + `v1427-measurements-add-param`, `v1427-insights-empty-state`, + `onboarding-tour-passthrough`). The literal `**/api/analytics` glob + Playwright minimatch-compiles does not match the sliced URL form + `/api/analytics?slice=summaries` that the v1.4.39.2 dashboard split + fires alongside the thick request, so the slim request fell through + to the real route and on the seeded test user that returns empty + summaries — exactly the failure mode the v1.4.39.3 production fix + guards against, but the regex form is the durable test-side + alignment with the v1.4.37 W-CI lesson that already swept + `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`. + +### Notes + +- No schema migration. Runtime + test-fixture-only changes. +- `pnpm test --run` green at 4662 / 4663 (1 long-standing skip); the + new `mergeSlimAndThickAnalytics` helper carries eight behaviour- + level unit tests covering the empty-slim, slim-only, thick-only, + both-empty, and `lastSeenByType` precedence cases. +- Full Playwright suite green at 116 passing / 36 skipped / 152 total + on both `chromium-desktop` and `chromium-mobile` profiles, locally + against the production Next.js build (`pnpm build && pnpm e2e`). + Closing every one of the eight failures from CI run + `26213226723`. + ## [1.4.39.2] — 2026-05-21 — Per-request rollup convergence and progressive dashboard paint v1.4.39.1 wired the rollup write hook into the previously-bypassed diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 64787e12a..048c116fd 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -70,7 +70,11 @@ test.describe("axe-core authenticated surfaces", () => { // need fully-painted DOM, and a flaky API would otherwise stall the // analyse() call until the 30s test timeout. test.beforeEach(async ({ page }) => { - await page.route("**/api/analytics", (route) => + // v1.4.39.3 — match `/api/analytics` AND any sliced variant + // (`?slice=summaries`); the v1.4.39.2 dashboard split fires both + // in parallel and the literal string glob misses the query- + // string form. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/chart-overlay-controls.spec.ts b/e2e/chart-overlay-controls.spec.ts index dec64dd30..c3bf48ca6 100644 --- a/e2e/chart-overlay-controls.spec.ts +++ b/e2e/chart-overlay-controls.spec.ts @@ -21,7 +21,13 @@ test.describe("chart overlay controls", () => { test.beforeEach(async ({ page }) => { // Mock analytics + measurements so the dashboard paints at least // one chart card. Same seed shape the dashboard.spec.ts uses. - await page.route("**/api/analytics", (route) => + // + // v1.4.39.3 — match `/api/analytics` AND any sliced variant + // (`?slice=summaries`). See the comment on dashboard.spec.ts for + // the underlying minimatch-vs-query-string gap; the regex form + // catches both the thick and slim slice URLs the dashboard now + // mounts in parallel. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/charts-mobile.spec.ts b/e2e/charts-mobile.spec.ts index 7c890bcc6..b21fca257 100644 --- a/e2e/charts-mobile.spec.ts +++ b/e2e/charts-mobile.spec.ts @@ -33,7 +33,13 @@ test.describe("charts — mobile-viewport regression", () => { // Mock analytics + measurements so the chart wrappers all clear // their data-floor gates. Same pattern as `dashboard.spec.ts`. - await page.route("**/api/analytics", (route) => + // + // v1.4.39.3 — match `/api/analytics` AND any sliced variant + // (`?slice=summaries`). The v1.4.39.2 dashboard split mounts the + // slim slice in parallel; the literal `**/api/analytics` glob + // misses the query-string form and dropped the request through to + // the real route, blanking the tile strip + chart cards. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 944b6ac39..60e54b87c 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -23,7 +23,18 @@ test.describe("authenticated dashboard render", () => { // Mock analytics with a populated weight summary so the weight tile // and chart render. `count` must be >0 for the tile to clear the // data-floor gate in `src/app/page.tsx`. - await page.route("**/api/analytics", (route) => + // + // v1.4.39.3 — match `/api/analytics` AND any sliced variant + // (`?slice=summaries`). The v1.4.39.2 dashboard split mounts two + // parallel `useAnalyticsQuery` calls; the slim variant carries a + // `?slice=summaries` query string that the literal string glob + // `**/api/analytics` (Playwright minimatch) refuses to match, + // dropping the request to the real route. The seeded test user has + // no measurements so the real route returns empty summaries, and + // the dashboard's slim-first merge then blanked the tile strip. + // The same trap bit `onboarding-flicker` and `mobile-viewport` in + // v1.4.37 W-CI; the regex form is the durable fix. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index e23e194a3..904095a1f 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -74,7 +74,14 @@ test.describe("add measurement flow", () => { // Dashboard's analytics fetch — empty summaries are fine; we only // need the page to render so we can click "Add". - await page.route("**/api/analytics", (route) => + // + // v1.4.39.3 — match `/api/analytics` AND any sliced variant + // (`?slice=summaries`). The v1.4.39.2 dashboard split fires both + // requests in parallel; the previous string glob missed the slim + // variant which then fell through to the real route and slowed + // the page paint enough that the post-mutation list query never + // surfaced inside the 10 s window. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/onboarding-tour-passthrough.spec.ts b/e2e/onboarding-tour-passthrough.spec.ts index 5e94b9237..558967f53 100644 --- a/e2e/onboarding-tour-passthrough.spec.ts +++ b/e2e/onboarding-tour-passthrough.spec.ts @@ -69,7 +69,9 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" }), ); - await page.route("**/api/analytics", (route) => + // v1.4.39.3 — regex form matches the slim slice the v1.4.39.2 + // dashboard split fires alongside the thick request. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 71b667f1f..1baae5fb9 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -31,7 +31,9 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => { // Populated analytics so each sub-page paints the data branch // (`hasMetricData` true) — the Coach button is mounted at the // bottom of the data tree. - await page.route("**/api/analytics", (route) => + // v1.4.39.3 — regex form matches the slim slice the v1.4.39.2 + // dashboard split fires alongside the thick request. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/v1427-insights-empty-state.spec.ts b/e2e/v1427-insights-empty-state.spec.ts index a3f51915f..cd30003cf 100644 --- a/e2e/v1427-insights-empty-state.spec.ts +++ b/e2e/v1427-insights-empty-state.spec.ts @@ -24,7 +24,9 @@ test.describe("v1.4.27 — insights empty-state with metric gating", () => { test.beforeEach(async ({ page }) => { // Zero-count summaries — every gated sub-page short-circuits to // its EmptyState branch. - await page.route("**/api/analytics", (route) => + // v1.4.39.3 — regex form matches the slim slice the v1.4.39.2 + // dashboard split fires alongside the thick request. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/v1427-measurements-add-param.spec.ts b/e2e/v1427-measurements-add-param.spec.ts index 41d7321c6..e208345e8 100644 --- a/e2e/v1427-measurements-add-param.spec.ts +++ b/e2e/v1427-measurements-add-param.spec.ts @@ -25,7 +25,9 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { - await page.route("**/api/analytics", (route) => + // v1.4.39.3 — regex form matches the slim slice the v1.4.39.2 + // dashboard split fires alongside the thick request. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/e2e/v1427-responsive-sheet.spec.ts b/e2e/v1427-responsive-sheet.spec.ts index 90a83edcc..b3e83e391 100644 --- a/e2e/v1427-responsive-sheet.spec.ts +++ b/e2e/v1427-responsive-sheet.spec.ts @@ -22,7 +22,9 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => { test.beforeEach(async ({ page }) => { // Empty analytics + list keeps the page fast and deterministic. - await page.route("**/api/analytics", (route) => + // v1.4.39.3 — regex form matches `/api/analytics` AND any sliced + // variant (`?slice=summaries`) the v1.4.39.2 dashboard split fires. + await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", diff --git a/package.json b/package.json index c8c6eaf5d..3f41bffba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.39.2", + "version": "1.4.39.3", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/page.tsx b/src/app/page.tsx index 39498c76a..95396dc6c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -66,6 +66,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import type { DataSummary } from "@/lib/analytics/trends"; +import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { buildTrafficLightBands, @@ -245,28 +246,30 @@ export default function DashboardPage() { const analyticsSlimQuery = useAnalyticsQuery({ slice: "summaries" }); const analyticsThickQuery = useAnalyticsQuery(); const data = useMemo(() => { - const slim = analyticsSlimQuery.data; - const thick = analyticsThickQuery.data; - if (!slim && !thick) return undefined; - // Slim wins on overlapping fields when both have resolved — slim - // and thick compute `summaries` / `lastSeenByType` from the same - // SQL helpers so the values agree, and reading from whichever - // resolves first keeps the tile strip painting as soon as either - // query lands. - const summaries = - slim?.summaries ?? thick?.summaries ?? ({} as Record); - const lastSeenByType = - slim?.lastSeenByType ?? thick?.lastSeenByType; + // v1.4.39.3 — the merge moved to `mergeSlimAndThickAnalytics` so + // the empty-slim-vs-populated-thick edge has direct unit + // coverage. Pre-fix the inline `slim?.summaries ?? thick?.summaries` + // 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 Marc's v1.4.39.3 e2e CI flagged across + // eight dashboard / chart specs. The helper falls back to thick + // when slim resolves with no content and otherwise keeps the + // v1.4.39.2 slim-wins-first progressive-paint contract. + const merged = mergeSlimAndThickAnalytics( + analyticsSlimQuery.data, + analyticsThickQuery.data, + ); + if (!merged) return undefined; return { - summaries, - lastSeenByType, - bpInTargetPct: (thick?.bpInTargetPct ?? null) as number | null, - bpInTargetPct7d: thick?.bpInTargetPct7d ?? null, - bpInTargetPct30d: thick?.bpInTargetPct30d ?? null, - bpInTargetPctAllTime: thick?.bpInTargetPctAllTime ?? null, - bpInTargetPctPriorMonth: thick?.bpInTargetPctPriorMonth ?? null, - bpInTargetPctPriorYear: thick?.bpInTargetPctPriorYear ?? null, - glucoseByContext: thick?.glucoseByContext as + summaries: merged.summaries, + lastSeenByType: merged.lastSeenByType, + bpInTargetPct: merged.bpInTargetPct, + bpInTargetPct7d: merged.bpInTargetPct7d, + bpInTargetPct30d: merged.bpInTargetPct30d, + bpInTargetPctAllTime: merged.bpInTargetPctAllTime, + bpInTargetPctPriorMonth: merged.bpInTargetPctPriorMonth, + bpInTargetPctPriorYear: merged.bpInTargetPctPriorYear, + glucoseByContext: merged.glucoseByContext as | Record | undefined, }; diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 99824c470..3f4b81715 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -544,7 +544,20 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { - {fmt.integer(m.value)} {m.unit} + {/* v1.4.39.3 — non-grouped rows render the + stored value with its native precision + (`fmt.number` honours up to 3 fraction + digits by default, drops trailing zeros) + so a 78.4 kg weight reading stops + truncating to "78". Grouped rows are + cumulative-type day aggregates (steps, + distance, etc.) and stay on `fmt.integer` + because the underlying readings are + integer-only by definition. */} + {isGrouped + ? fmt.integer(m.value) + : fmt.number(m.value)}{" "} + {m.unit} {isGrouped && ( {t("measurements.dailyTotalCaption", { @@ -683,7 +696,15 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { )} - {fmt.integer(m.value)} {m.unit} + {/* v1.4.39.3 — mirror the desktop table: + grouped daily aggregates stay integer, + non-grouped single readings render with + their native decimal precision so + "78.4 kg" no longer truncates to "78". */} + {isGrouped + ? fmt.integer(m.value) + : fmt.number(m.value)}{" "} + {m.unit} {isGrouped && ( diff --git a/src/lib/analytics/__tests__/merge-slim-thick.test.ts b/src/lib/analytics/__tests__/merge-slim-thick.test.ts new file mode 100644 index 000000000..bcedca54a --- /dev/null +++ b/src/lib/analytics/__tests__/merge-slim-thick.test.ts @@ -0,0 +1,141 @@ +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 Marc'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, + avg7: 78.2, + avg30: 77.9, + slope7: null, + slope30: { slope: -0.05, direction: "down", confidence: 0.5 }, + slope90: null, + 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 new file mode 100644 index 000000000..ba7018ffc --- /dev/null +++ b/src/lib/analytics/merge-slim-thick.ts @@ -0,0 +1,114 @@ +/** + * 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"; + +export interface AnalyticsSlimLike { + summaries?: Record | null; + lastSeenByType?: Record< + string, + { lastSeenAt: string; daysAgo: number } | null + > | null; +} + +export interface AnalyticsThickLike extends AnalyticsSlimLike { + bpInTargetPct?: number | null; + bpInTargetPct7d?: number | null; + bpInTargetPct30d?: number | null; + bpInTargetPctAllTime?: number | null; + bpInTargetPctPriorMonth?: number | null; + bpInTargetPctPriorYear?: 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; + glucoseByContext: Record | undefined; +} + +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, + glucoseByContext: thick?.glucoseByContext ?? undefined, + }; +}