mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Reference-band fills, classification tones, and the trend-chart palette carried hardcoded hex that rendered identically in both themes and glared on the light surface. Route them through the semantic and chart CSS variables, which already hold per-theme values, so the light theme gets the muted set and the dark theme stays byte-identical.
171 lines
4.7 KiB
TypeScript
171 lines
4.7 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,
|
|
};
|
|
}
|
|
|
|
export function buildWeightBandsFromHeight(
|
|
heightCm: number,
|
|
options: { lowerBound?: number; upperBound?: number } = {},
|
|
): ValueBand[] {
|
|
const range = buildWeightRangeFromHeight(heightCm);
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|