Files
HealthLog/src/lib/analytics/value-bands.ts
T
Marc-André Bombeck b1b76ab68a fix(score): grade weight against the user's own target, never a fabricated one
The Health Score graded the weight pillar against 22 x height squared,
expanded by 2 kg either side: a target nobody set, nothing disclosed,
and the only producer of the pillar's band. For anyone with a height on
file it pre-empted both the honest trend-only path the module header
promised and the null-redistribution behind it, while the target the
user had actually entered sat unread in User.thresholdsJson.

computeHealthScore now takes the band whole plus the yardstick it came
from. Both callers resolve it through one shared builder next to
buildHealthScoreBpInputs, which hands over a band only when
getEffectiveRange reports a real user override; every other account
scores the bare trend. defaultWeightTargetFromHeight is gone.

Same pass, same defect class elsewhere: the dashboard bands, the
weight-drift verdict rung and the weight/BMI record direction all
ignored an explicit target too, and the snapshot never resolved the
mood module, so a mood-off account got a mood pillar in the hero ring
and none in the Insights card. moodEnabled and the weight yardstick are
required parameters now, so a caller that forgets one fails to compile
instead of quietly scoring against a default.
2026-07-27 22:54:48 +02:00

189 lines
5.2 KiB
TypeScript

export interface ValueBand {
min: number;
max: number;
color: string;
opacity?: number;
strokeOpacity?: number;
}
export interface TrafficRange {
greenMin: number;
greenMax: number;
orangeMin: number;
orangeMax: number;
}
interface TrafficBandOptions {
lowerBound?: number;
upperBound?: number;
orangeFactor?: number;
}
export function buildTrafficLightBands(
min: number,
max: number,
options: TrafficBandOptions = {},
): ValueBand[] {
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
return [];
}
const span = max - min;
const orangeFactor = options.orangeFactor ?? 0.3;
const orangeWidth = span * orangeFactor;
const orangeLow = min - orangeWidth;
const orangeHigh = max + orangeWidth;
const lowerBound =
options.lowerBound ?? Math.max(0, orangeLow - Math.max(span, 1) * 2);
const upperBound = options.upperBound ?? orangeHigh + Math.max(span, 1) * 2;
const bands: ValueBand[] = [
{
min: lowerBound,
max: orangeLow,
color: "var(--destructive)",
opacity: 0.16,
},
{ min: orangeLow, max: min, color: "var(--warning)", opacity: 0.18 },
{ min, max, color: "var(--success)", opacity: 0.2 },
{ min: max, max: orangeHigh, color: "var(--warning)", opacity: 0.18 },
{
min: orangeHigh,
max: upperBound,
color: "var(--destructive)",
opacity: 0.16,
},
];
return bands.filter((band) => band.max > band.min);
}
export function buildTrafficRange(
min: number,
max: number,
orangeFactor = 0.3,
): TrafficRange | null {
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
return null;
}
const span = max - min;
const orangeWidth = span * orangeFactor;
return {
greenMin: min,
greenMax: max,
orangeMin: min - orangeWidth,
orangeMax: max + orangeWidth,
};
}
export function buildWeightRangeFromHeight(heightCm: number): TrafficRange {
const heightM = heightCm / 100;
const h2 = heightM * heightM;
return {
greenMin: 18.5 * h2,
greenMax: 24.9 * h2,
orangeMin: 17 * h2,
orangeMax: 29.9 * h2,
};
}
/**
* Chart bands straight off an already-resolved traffic range.
*
* v1.34 — the weight chart shades either the height-derived WHO band or the
* user's OWN target band (`User.thresholdsJson`, resolved through
* `getEffectiveRange`, which computes its own orange wings). Both go through
* this one builder so the two yardsticks paint with identical geometry and
* only the numbers differ.
*/
export function buildBandsFromTrafficRange(
range: TrafficRange,
options: { lowerBound?: number; upperBound?: number } = {},
): ValueBand[] {
const lowerBound = options.lowerBound ?? 30;
const upperBound = options.upperBound ?? 250;
return [
{
min: lowerBound,
max: range.orangeMin,
color: "var(--destructive)",
opacity: 0.16,
},
{
min: range.orangeMin,
max: range.greenMin,
color: "var(--warning)",
opacity: 0.18,
},
{
min: range.greenMin,
max: range.greenMax,
color: "var(--success)",
opacity: 0.2,
},
{
min: range.greenMax,
max: range.orangeMax,
color: "var(--warning)",
opacity: 0.18,
},
{
min: range.orangeMax,
max: upperBound,
color: "var(--destructive)",
opacity: 0.16,
},
].filter((band) => band.max > band.min);
}
export function buildWeightBandsFromHeight(
heightCm: number,
options: { lowerBound?: number; upperBound?: number } = {},
): ValueBand[] {
return buildBandsFromTrafficRange(
buildWeightRangeFromHeight(heightCm),
options,
);
}
/**
* Body-fat target range — the "green" band shown on the dashboard /
* targets page / chart value-bands.
*
* Source: ACE (American Council on Exercise) body-composition standards
* https://www.acefitness.org/resources/everyone/blog/112/what-are-the-guidelines-for-percentage-of-body-fat-loss/
* Bands per ACE:
* - Essential: M 2-5 / F 10-13 (warning band, very low; below ACE
* "essential" is dangerous)
* - Athletes: M 6-13 / F 14-20 (clinically lean)
* - Fitness: M 14-17 / F 21-24 (target floor for the typical
* non-athlete)
* - Acceptable:M 18-24 / F 25-31 (target ceiling)
* - Obese: M 25+ / F 32+ (warning)
*
* The "green" band combines Fitness + Acceptable so the typical user's
* realistic healthy range is shown, not the athlete band. Three sites
* had different numbers in v1.3.3 — this is now the single source of
* truth (targets/route.ts and chart value-bands import this helper).
*
* Cross-checked against:
* - Heyward V & Wagner D, "Applied Body Composition Assessment" 2nd ed
* (the underlying source ACE references for its public table).
* - WHO Expert Consultation 2008 ("Waist circumference and waist-hip
* ratio") — does NOT publish percent-fat bands; included here only
* to flag that "WHO body-fat thresholds" is a recurring
* hallucination to avoid.
*/
export function getBodyFatTargetRange(gender: string | null | undefined): {
min: number;
max: number;
} {
if (gender === "MALE") return { min: 14, max: 24 };
if (gender === "FEMALE") return { min: 21, max: 31 };
// Gender-neutral fallback: midpoint of male/female fitness+acceptable.
return { min: 17, max: 27 };
}