From bd8055774953636118f4f212c59e80b5869b0b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 15:29:09 +0200 Subject: [PATCH] perf(dashboard): compute chart band/target math server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the BP/pulse/body-fat/weight band + target derivation out of the dashboard client component and into the snapshot DTO. The page recomputed every band from the user profile on each render via the analytics band helpers; the snapshot builder now resolves the same numeric structures once server-side under a new targetBands block, calling the identical helpers. Emit only the i18n-free numbers — the label-bearing BP target zones stay client-assembled from the server numbers. Add a parity test asserting buildTargetBands matches the prior client derivation for a male, a female, and a no-profile user. --- e2e/utils/mock-dashboard-snapshot.ts | 73 +++++++++ .../hero/__tests__/dashboard-hero.test.tsx | 6 + .../dashboard/__tests__/target-bands.test.ts | 142 +++++++++++++++++ src/lib/dashboard/__tests__/verdict.test.ts | 6 + src/lib/dashboard/snapshot.ts | 146 +++++++++++++++++- 5 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/lib/dashboard/__tests__/target-bands.test.ts diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index cdc207dd8..e1f626569 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -8,7 +8,79 @@ import type { DataSummary } from "@/lib/analytics/trends"; import type { DashboardSnapshot, DashboardLayoutCatalogueEntry, + DashboardTargetBands, } from "@/lib/dashboard/snapshot"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + buildTrafficLightBands, + buildTrafficRange, + buildWeightBandsFromHeight, + buildWeightRangeFromHeight, + getBodyFatTargetRange, +} from "@/lib/analytics/value-bands"; +import { + getAgeFromDateOfBirth, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; + +// v1.18.6 — resolve the mock's band block from the same pure helpers the +// server builder uses, so the e2e fixture matches the real DTO shape. +function mockTargetBands(): DashboardTargetBands { + const dob = new Date("1990-01-01T00:00:00.000Z"); + const bpTargets = getBpTargets(dob); + const pulseTarget = getPersonalizedPulseTarget( + getAgeFromDateOfBirth(dob), + "MALE", + ); + const bodyFatRange = getBodyFatTargetRange("MALE"); + return { + bpTargets, + bpSysRange: bpTargets + ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) + : null, + bpDiaRange: bpTargets + ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) + : null, + pulseDisplayRange: { + greenMin: pulseTarget.greenMin, + greenMax: pulseTarget.greenMax, + orangeMin: pulseTarget.orangeMin, + orangeMax: pulseTarget.orangeMax, + }, + pulseBands: [ + { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMin, + max: pulseTarget.greenMin, + color: "#ffb86c", + opacity: 0.18, + }, + { + min: pulseTarget.greenMin, + max: pulseTarget.greenMax, + color: "#50fa7b", + opacity: 0.2, + }, + { + min: pulseTarget.greenMax, + max: pulseTarget.orangeMax, + color: "#ffb86c", + opacity: 0.18, + }, + { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + ].filter((b) => b.max > b.min), + bodyFatRange, + bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { + lowerBound: 2, + upperBound: 55, + }), + weightRange: buildWeightRangeFromHeight(180), + weightBands: buildWeightBandsFromHeight(180, { + lowerBound: 30, + upperBound: 250, + }), + }; +} /** * v1.7.2 — shared mock for `GET /api/dashboard/snapshot`. @@ -138,6 +210,7 @@ export function buildMockSnapshot( layout: DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), metricStates: {}, + targetBands: mockTargetBands(), tiles: { summaries, lastSeenByType, diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 451b8af0a..85c65068c 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -6,6 +6,7 @@ import { join } from "node:path"; import { I18nProvider } from "@/lib/i18n/context"; import { makeFormatters } from "@/lib/format-locale"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; +import { buildTargetBands } from "@/lib/dashboard/snapshot"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { DataSummary } from "@/lib/analytics/trends"; import { DashboardHero } from "../dashboard-hero"; @@ -92,6 +93,11 @@ function baseSnapshot( layout: { version: 1, widgets: [] }, layoutCatalogue: [], metricStates: {}, + targetBands: buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: null, + }), tiles: { summaries: {}, lastSeenByType: {}, diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts new file mode 100644 index 000000000..56aa6d1ab --- /dev/null +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -0,0 +1,142 @@ +/** + * v1.18.6 — parity test for the server-side band / target math + * (audit finding #3). `buildTargetBands` must produce byte-identical + * numbers to the legacy client-side derivation in `page.tsx`, which is + * just a sequence of the SAME pure helpers. The test re-derives each + * structure with those helpers and asserts deep equality for a male + * profile, a female profile, and a no-profile user. + */ +import { describe, it, expect } from "vitest"; + +import { buildTargetBands } from "../snapshot"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + buildTrafficLightBands, + buildTrafficRange, + buildWeightBandsFromHeight, + buildWeightRangeFromHeight, + getBodyFatTargetRange, +} from "@/lib/analytics/value-bands"; +import { + getAgeFromDateOfBirth, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; + +/** Re-derive bands exactly as `page.tsx` did before v1.18.6. */ +function clientBands(profile: { + dateOfBirth: Date | null; + gender: "MALE" | "FEMALE" | null; + heightCm: number | null; +}) { + const bpTargets = profile.dateOfBirth + ? getBpTargets(profile.dateOfBirth) + : null; + const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth); + const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender); + const bodyFatRange = getBodyFatTargetRange(profile.gender); + const weightRange = profile.heightCm + ? buildWeightRangeFromHeight(profile.heightCm) + : null; + const weightBands = profile.heightCm + ? buildWeightBandsFromHeight(profile.heightCm, { + lowerBound: 30, + upperBound: 250, + }) + : null; + return { + bpTargets, + bpSysRange: bpTargets + ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) + : null, + bpDiaRange: bpTargets + ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) + : null, + pulseDisplayRange: { + greenMin: pulseTarget.greenMin, + greenMax: pulseTarget.greenMax, + orangeMin: pulseTarget.orangeMin, + orangeMax: pulseTarget.orangeMax, + }, + pulseBands: [ + { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMin, + max: pulseTarget.greenMin, + color: "#ffb86c", + opacity: 0.18, + }, + { + min: pulseTarget.greenMin, + max: pulseTarget.greenMax, + color: "#50fa7b", + opacity: 0.2, + }, + { + min: pulseTarget.greenMax, + max: pulseTarget.orangeMax, + color: "#ffb86c", + opacity: 0.18, + }, + { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + ].filter((b) => b.max > b.min), + bodyFatRange, + bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { + lowerBound: 2, + upperBound: 55, + }), + weightRange, + weightBands, + }; +} + +describe("buildTargetBands — server/client parity", () => { + const profiles: Array<{ + name: string; + dateOfBirth: Date | null; + gender: "MALE" | "FEMALE" | null; + heightCm: number | null; + }> = [ + { + name: "male 180cm with DOB", + dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, + }, + { + name: "female 165cm with DOB", + dateOfBirth: new Date("1970-01-01T00:00:00.000Z"), + gender: "FEMALE", + heightCm: 165, + }, + { + name: "no-profile user (null DOB / gender / height)", + dateOfBirth: null, + gender: null, + heightCm: null, + }, + ]; + + for (const profile of profiles) { + it(`matches the legacy client math for ${profile.name}`, () => { + const server = buildTargetBands(profile); + const client = clientBands(profile); + expect(server).toEqual(client); + }); + } + + it("nulls the profile-derived bands when no DOB / height", () => { + const bands = buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: null, + }); + expect(bands.bpTargets).toBeNull(); + expect(bands.bpSysRange).toBeNull(); + expect(bands.bpDiaRange).toBeNull(); + expect(bands.weightRange).toBeNull(); + expect(bands.weightBands).toBeNull(); + // Pulse + body-fat always resolve (AHA / neutral fallback). + expect(bands.pulseBands.length).toBeGreaterThan(0); + expect(bands.bodyFatBands.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index c5f704c19..3aec74909 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect } from "vitest"; import { resolveDashboardVerdict } from "../verdict"; +import { buildTargetBands } from "../snapshot"; import type { DashboardSnapshot } from "../snapshot"; import type { MedsTodayBlock } from "../meds-today"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -73,6 +74,11 @@ function baseSnapshot( layout: { version: 1, widgets: [] }, layoutCatalogue: [], metricStates: {}, + targetBands: buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: null, + }), tiles: { summaries: {}, lastSeenByType: {}, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 8452a9dcd..969e4e9fa 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -45,7 +45,20 @@ import { type DataPoint, type DataSummary, } from "@/lib/analytics/trends"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets"; +import { + buildTrafficLightBands, + buildTrafficRange, + buildWeightBandsFromHeight, + buildWeightRangeFromHeight, + getBodyFatTargetRange, + type ValueBand, + type TrafficRange, +} from "@/lib/analytics/value-bands"; +import { + getAgeFromDateOfBirth, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, @@ -196,6 +209,48 @@ export interface DashboardSnapshotUser { greetingHour: number; } +/** + * v1.18.6 — server-computed band / target math (audit finding #3). + * + * The dashboard used to derive every chart band + tile range CLIENT-side + * in `page.tsx` from `user.dateOfBirth` / `gender` / `heightCm`. This + * block resolves the same numbers server-side from the EXISTING band + * helpers (`getBpTargets`, `getPersonalizedPulseTarget`, + * `getBodyFatTargetRange`, `buildWeight*FromHeight`, + * `buildTrafficLightBands`, `buildTrafficRange`) so the client reads them + * instead of recomputing. Only the NUMERIC structures live here — the + * `bpTargetZones` array carries `t()`-localised labels, so the client + * assembles THAT from `bpTargets` (numbers) at render time. Every field + * is `null` when the driving profile fact is missing (no DOB → BP/pulse + * personalisation, no height → weight band), matching the client's + * previous null-guards exactly. + */ +export interface DashboardTargetBands { + /** Personalised BP target numbers (null when no DOB). */ + bpTargets: BpTargets | null; + /** Systolic traffic range (null when no DOB). */ + bpSysRange: TrafficRange | null; + /** Diastolic traffic range (null when no DOB). */ + bpDiaRange: TrafficRange | null; + /** Resting-pulse display range (always present — AHA fallback). */ + pulseDisplayRange: { + greenMin: number; + greenMax: number; + orangeMin: number; + orangeMax: number; + }; + /** Resting-pulse chart bands (always present — AHA fallback). */ + pulseBands: ValueBand[]; + /** Body-fat target range (gender-aware; always present). */ + bodyFatRange: { min: number; max: number }; + /** Body-fat chart bands (always present). */ + bodyFatBands: ValueBand[]; + /** Weight traffic range (null when no height). */ + weightRange: TrafficRange | null; + /** Weight chart bands (null when no height). */ + weightBands: ValueBand[] | null; +} + export interface DashboardSnapshotMoodEntry { date: string; score: number; @@ -301,6 +356,12 @@ export interface DashboardSnapshot { * `MetricKind` raw value. Additive; the web page does not read it. */ metricStates: Record; + /** + * v1.18.6 — server-computed band / target math (audit finding #3). The + * web dashboard reads these instead of recomputing from the profile; + * additive for iOS. + */ + targetBands: DashboardTargetBands; /** Fast phase — always present. */ tiles: { summaries: Record; @@ -600,6 +661,84 @@ async function buildExtras( }; } +/** + * v1.18.6 — resolve the band / target math server-side from the same + * helpers the client used. Pure projection over the profile facts; no DB + * read. Mirrors the exact construction `page.tsx` ran (same helper calls, + * same literal pulse-band colours/opacities, same null-guards) so the + * snapshot path and the legacy client-compute path produce byte-identical + * numbers. + */ +export function buildTargetBands(profile: { + dateOfBirth: Date | null; + gender: "MALE" | "FEMALE" | null; + heightCm: number | null; +}): DashboardTargetBands { + const bpTargets = profile.dateOfBirth + ? getBpTargets(profile.dateOfBirth) + : null; + const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth); + const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender); + const bodyFatRange = getBodyFatTargetRange(profile.gender); + const weightRange = profile.heightCm + ? buildWeightRangeFromHeight(profile.heightCm) + : null; + const weightBands = profile.heightCm + ? buildWeightBandsFromHeight(profile.heightCm, { + lowerBound: 30, + upperBound: 250, + }) + : null; + const bpSysRange = bpTargets + ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) + : null; + const bpDiaRange = bpTargets + ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) + : null; + const pulseBands = [ + { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMin, + max: pulseTarget.greenMin, + color: "#ffb86c", + opacity: 0.18, + }, + { + min: pulseTarget.greenMin, + max: pulseTarget.greenMax, + color: "#50fa7b", + opacity: 0.2, + }, + { + min: pulseTarget.greenMax, + max: pulseTarget.orangeMax, + color: "#ffb86c", + opacity: 0.18, + }, + { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + ].filter((band) => band.max > band.min); + const bodyFatBands = buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { + lowerBound: 2, + upperBound: 55, + }); + return { + bpTargets, + bpSysRange, + bpDiaRange, + pulseDisplayRange: { + greenMin: pulseTarget.greenMin, + greenMax: pulseTarget.greenMax, + orangeMin: pulseTarget.orangeMin, + orangeMax: pulseTarget.orangeMax, + }, + pulseBands, + bodyFatRange, + bodyFatBands, + weightRange, + weightBands, + }; +} + /** Parse + validate the cached `dailyBriefing` block; null when unusable. */ function parseCachedBriefing( cachedText: string | null, @@ -968,6 +1107,11 @@ export async function buildDashboardSnapshot( layout, layoutCatalogue: buildLayoutCatalogue(layout), metricStates: buildMetricStates(slim.summaries, slim.lastSeenByType), + targetBands: buildTargetBands({ + dateOfBirth: user.dateOfBirth, + gender, + heightCm: user.heightCm ?? null, + }), tiles: { summaries: slim.summaries, lastSeenByType: enrichLastSeen(slim.lastSeenByType, nowMs),