mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
dd44ddeb70
commit
bd80557749
5 files changed
+372
-1
No files matched your search
@@ -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: {},
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user