From 63bca26177d3435fb4bd9d7010fd008faedd226c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 19:02:05 +0200 Subject: [PATCH] refactor(dashboard): extract buildDashboardBands into a client-safe leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/page.tsx | 106 ++++++------------------- src/lib/dashboard/bands.ts | 143 ++++++++++++++++++++++++++++++++++ src/lib/dashboard/snapshot.ts | 125 ++--------------------------- 3 files changed, 173 insertions(+), 201 deletions(-) create mode 100644 src/lib/dashboard/bands.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index c7a111143..8de115c44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 `` fired its own diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts new file mode 100644 index 000000000..8f2d1aed0 --- /dev/null +++ b/src/lib/dashboard/bands.ts @@ -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; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 969e4e9fa..668e38944 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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,