mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
f22e39872e
commit
63bca26177
3 files changed
+173
-201
No files matched your search
+22
-84
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user