refactor(dashboard): extract buildDashboardBands into a client-safe leaf

The band / target math lived inline in two places — the server snapshot
builder and the dashboard page's snapshot-disabled fallback — so a
threshold change had to be hand-mirrored or the two surfaces would grade
the same metric differently. Move the construction into the new
client-safe @/lib/dashboard/bands leaf (buildDashboardBands, with
buildTargetBands kept as an alias for the snapshot-era call sites) and
have both the snapshot builder and the page fallback call it. The page
always computes the client copy from the profile so a still-loading
snapshot frame keeps real bands, then prefers the server numbers once
they arrive. Behaviour-preserving.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 19:02:05 +02:00
1 parent f22e39872e
commit 63bca26177
3 files changed
+173 -201

No files matched your search

+22 -84
View File
@@ -106,18 +106,7 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import type { DataSummary } from "@/lib/analytics/trends";
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
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";
import { buildDashboardBands } from "@/lib/dashboard/bands";
import { apiGet } from "@/lib/api/api-fetch";
// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to
@@ -537,34 +526,22 @@ export default function DashboardPage() {
const serverBands = snapshotEnabled
? snapshotQuery.data?.targetBands
: undefined;
const bpTargets = snapshotEnabled
? (serverBands?.bpTargets ?? null)
: user?.dateOfBirth != null
? getBpTargets(new Date(user.dateOfBirth))
: null;
const bodyFatRange = snapshotEnabled
? (serverBands?.bodyFatRange ?? getBodyFatTargetRange(user?.gender))
: getBodyFatTargetRange(user?.gender);
const weightRange = snapshotEnabled
? (serverBands?.weightRange ?? null)
: user?.heightCm
? buildWeightRangeFromHeight(user.heightCm)
: null;
const weightBands = snapshotEnabled
? (serverBands?.weightBands ?? undefined)
: user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
// The pulse target range / bands carry no i18n — read them server-side
// under snapshot mode; recompute from the profile otherwise.
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
);
// Resolve the bands through the SAME `buildDashboardBands` helper the
// server snapshot uses, so the snapshot path and the client fallback
// are byte-identical by construction (no second inline copy of the
// band math to drift). Always compute the client copy from the profile
// — it is pure + cheap — so a still-loading snapshot frame keeps real
// bands rather than blank charts; `serverBands ?? clientBands` then
// prefers the authoritative server numbers the moment they arrive.
const clientBands = buildDashboardBands({
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
heightCm: user?.heightCm ?? null,
});
const bands = serverBands ?? clientBands;
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
const weightBands = bands.weightBands ?? undefined;
const bpTargetZones = bpTargets
? [
{
@@ -587,50 +564,11 @@ export default function DashboardPage() {
},
]
: undefined;
const bpSysRange = snapshotEnabled
? (serverBands?.bpSysRange ?? null)
: bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null;
const bpDiaRange = snapshotEnabled
? (serverBands?.bpDiaRange ?? null)
: bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null;
const pulseDisplayRange = serverBands?.pulseDisplayRange ?? {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
};
const pulseBands = serverBands?.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 =
serverBands?.bodyFatBands ??
buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
});
const bpSysRange = bands.bpSysRange;
const bpDiaRange = bands.bpDiaRange;
const pulseDisplayRange = bands.pulseDisplayRange;
const pulseBands = bands.pulseBands;
const bodyFatBands = bands.bodyFatBands;
// v1.18.6 — ONE batched fetch for every visible non-sleep chart series
// (audit finding #2). Pre-fix, each `<HealthChart>` fired its own
+143
View File
@@ -0,0 +1,143 @@
/**
* Dashboard band / target math — the single source of truth.
*
* Pure projection over a user's profile facts (DOB / gender / height):
* no DB read, no clock, no network, no `server-only` dep. Every building
* block it composes (`@/lib/analytics/{value-bands,bp-targets,pulse-targets}`)
* is itself client-safe, so this leaf imports cleanly into both the
* server-side snapshot builder (`@/lib/dashboard/snapshot`) and the
* `"use client"` dashboard page fallback (`src/app/page.tsx`).
*
* Before v1.18.6 the band construction lived inline in two places — the
* snapshot builder and the page's snapshot-disabled fallback — so a
* threshold change had to be mirrored by hand or the two surfaces would
* grade the same metric differently. Both now call `buildDashboardBands`,
* so the band/target math lives in exactly one place. `buildTargetBands`
* is kept as an alias for the snapshot-era import sites.
*/
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";
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;
}
/**
* Resolve the band / target math from a user's profile facts. Pure; no
* DB read. The snapshot builder and the page fallback both call this so
* they produce byte-identical numbers.
*/
export function buildDashboardBands(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,
};
}
/**
* Snapshot-era alias. The snapshot builder and its parity test still
* import `buildTargetBands`; keep the name pointing at the canonical
* builder so neither has to change.
*/
export const buildTargetBands = buildDashboardBands;
+8 -117
View File
@@ -45,20 +45,7 @@ import {
type DataPoint,
type DataSummary,
} from "@/lib/analytics/trends";
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 { getBpTargets } from "@/lib/analytics/bp-targets";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -90,6 +77,11 @@ import {
type MedsTodayBlock,
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import {
buildDashboardBands,
buildTargetBands,
type DashboardTargetBands,
} from "@/lib/dashboard/bands";
import type {
HealthScoreBand,
RestModeAnnotation,
@@ -225,31 +217,8 @@ export interface DashboardSnapshotUser {
* 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 { buildDashboardBands, buildTargetBands };
export type { DashboardTargetBands };
export interface DashboardSnapshotMoodEntry {
date: string;
@@ -661,84 +630,6 @@ 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,