mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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