perf(dashboard): compute chart band/target math server-side

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 15:29:09 +02:00
1 parent dd44ddeb70
commit bd80557749
5 files changed
+372 -1

No files matched your search

+73
View File
@@ -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,
@@ -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: {},
@@ -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);
});
});
@@ -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: {},
+145 -1
View File
@@ -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<string, DashboardMetricState>;
/**
* 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<string, DataSummary>;
@@ -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),