mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
189 lines
5.2 KiB
TypeScript
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 };
|
|
}
|