mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
chore(release): v1.4.39.3 — dashboard slim/thick merge robustness and list-page precision
The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across chromium-desktop and chromium-mobile (chart-overlay-controls, dashboard, charts-mobile, measurement-flow) that all routed through two narrow regressions and one durable test-fixture gap. ### Fixed - Dashboard slim/thick merge no longer blanks the tile strip when the slim slice resolves with an empty summaries record. The v1.4.39.2 inline `slim?.summaries ?? thick?.summaries` short-circuited on JavaScript's truthy-empty-object even when thick carried the full payload. The merge moved to a pure `mergeSlimAndThickAnalytics` helper that uses object emptiness as the discriminator: populated slim still wins on overlapping fields (progressive-paint contract preserved), empty slim falls through to thick, both empty leaves the dashboard's data-floor gates to render the appropriate empty state. Eight new behaviour-level unit tests cover the edge cases. - `MeasurementList` non-grouped rows now preserve decimal precision on both the desktop table and the mobile card grid. 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 / temperature readings — 78.4 kg rendered as "78 kg". Grouped rows (cumulative HK type day aggregates) stay on `fmt.integer` because their readings are integer-only; non-grouped rows now use `fmt.number` which honours up to three fraction digits without forcing a minimum. ### Changed - Playwright route mocks for `/api/analytics` migrate from the literal `**/api/analytics` string glob to the regex form `/\/api\/analytics(\?|$)/` across every authenticated spec. The string glob does not match the sliced URL form `/api/analytics?slice=summaries` the v1.4.39.2 dashboard split fires alongside the thick request, so the slim request fell through to the real route on the seeded test user that returns empty summaries. Ten specs migrate: 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 regex form is the durable 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). - Full Playwright suite green at 116 passing / 36 skipped / 152 total on both `chromium-desktop` and `chromium-mobile` profiles, against the production Next.js build. Closes every failure from CI run 26213226723.
This commit is contained in:
1 parent
d665a5db42
commit
75b8c57022
16 files changed
+426
-34
No files matched your search
@@ -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
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+24
-21
@@ -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<AnalyticsData | undefined>(() => {
|
||||
const slim = analyticsSlimQuery.data;
|
||||
const thick = analyticsThickQuery.data;
|
||||
if (!slim && !thick) return undefined;
|
||||
// Slim wins on overlapping fields when both have resolved — slim
|
||||
// and thick compute `summaries` / `lastSeenByType` from the same
|
||||
// SQL helpers so the values agree, and reading from whichever
|
||||
// resolves first keeps the tile strip painting as soon as either
|
||||
// query lands.
|
||||
const summaries =
|
||||
slim?.summaries ?? thick?.summaries ?? ({} as Record<string, DataSummary>);
|
||||
const lastSeenByType =
|
||||
slim?.lastSeenByType ?? thick?.lastSeenByType;
|
||||
// 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<string, DataSummaryType>
|
||||
| undefined,
|
||||
};
|
||||
|
||||
@@ -544,7 +544,20 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{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 && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
{t("measurements.dailyTotalCaption", {
|
||||
@@ -683,7 +696,15 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
</Badge>
|
||||
)}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{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}
|
||||
</span>
|
||||
{isGrouped && (
|
||||
<span className="text-muted-foreground ml-1.5 text-[11px]">
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, DataSummary> | 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<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;
|
||||
glucoseByContext: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
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,
|
||||
glucoseByContext: thick?.glucoseByContext ?? undefined,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user