From c6dea5eb5615120581f34a73de4379ea1289747d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:44:55 +0200 Subject: [PATCH] refactor(insights): route chart and classification colors through theme tokens 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. --- src/lib/analytics/classifications.ts | 108 ++++++++++++------ src/lib/analytics/pulse-targets.ts | 14 ++- src/lib/analytics/value-bands.ts | 40 +++++-- .../dashboard/__tests__/target-bands.test.ts | 20 +++- src/lib/dashboard/bands.ts | 20 +++- src/lib/insights/trend-chart-select.ts | 24 ++-- src/lib/signals/registry.ts | 6 +- src/lib/targets/build-response.ts | 33 +++--- 8 files changed, 180 insertions(+), 85 deletions(-) diff --git a/src/lib/analytics/classifications.ts b/src/lib/analytics/classifications.ts index 9eba5b3df..293a4d27d 100644 --- a/src/lib/analytics/classifications.ts +++ b/src/lib/analytics/classifications.ts @@ -13,35 +13,39 @@ export interface BmiClassification { export function classifyBMI(bmi: number): BmiClassification { if (bmi < 18.5) { - return { category: "Underweight", color: "#f1fa8c", severity: "warning" }; + return { + category: "Underweight", + color: "var(--dracula-yellow)", + severity: "warning", + }; } if (bmi < 25) { - return { category: "Normal", color: "#50fa7b", severity: "normal" }; + return { category: "Normal", color: "var(--success)", severity: "normal" }; } if (bmi < 30) { return { category: "Overweight", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } if (bmi < 35) { return { category: "Obesity Grade I", - color: "#ff79c6", + color: "var(--chart-3)", severity: "danger", }; } if (bmi < 40) { return { category: "Obesity Grade II", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } return { category: "Obesity Grade III", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } @@ -56,35 +60,35 @@ export interface BpClassification { export function classifyBP(sys: number, dia: number): BpClassification { if (sys < 120 && dia < 80) { - return { category: "Optimal", color: "#50fa7b", severity: "normal" }; + return { category: "Optimal", color: "var(--success)", severity: "normal" }; } if (sys < 130 && dia < 85) { - return { category: "Normal", color: "#8be9fd", severity: "normal" }; + return { category: "Normal", color: "var(--info)", severity: "normal" }; } if (sys < 140 && dia < 90) { return { category: "High-normal", - color: "#f1fa8c", + color: "var(--dracula-yellow)", severity: "elevated", }; } if (sys < 160 && dia < 100) { return { category: "Hypertension Grade 1", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } if (sys < 180 && dia < 110) { return { category: "Hypertension Grade 2", - color: "#ff79c6", + color: "var(--chart-3)", severity: "danger", }; } return { category: "Hypertension Grade 3", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } @@ -99,15 +103,23 @@ export interface PulseClassification { export function classifyPulse(bpm: number): PulseClassification { if (bpm < 60) { - return { category: "Bradycardia", color: "#8be9fd", severity: "info" }; + return { category: "Bradycardia", color: "var(--info)", severity: "info" }; } if (bpm <= 100) { - return { category: "Normal", color: "#50fa7b", severity: "normal" }; + return { category: "Normal", color: "var(--success)", severity: "normal" }; } if (bpm <= 120) { - return { category: "Elevated", color: "#ffb86c", severity: "warning" }; + return { + category: "Elevated", + color: "var(--warning)", + severity: "warning", + }; } - return { category: "Tachycardia", color: "#ff5555", severity: "danger" }; + return { + category: "Tachycardia", + color: "var(--destructive)", + severity: "danger", + }; } // ── Sleep Duration Classification (AASM/SRS) ─────────── @@ -124,22 +136,34 @@ export function classifySleepDuration( if (hours < 6) { return { category: "Far too short", - color: "#ff5555", + color: "var(--destructive)", severity: "warning", }; } if (hours < 7) { - return { category: "Too short", color: "#ffb86c", severity: "warning" }; + return { + category: "Too short", + color: "var(--warning)", + severity: "warning", + }; } if (hours <= 9) { - return { category: "On target", color: "#50fa7b", severity: "normal" }; + return { + category: "On target", + color: "var(--success)", + severity: "normal", + }; } if (hours <= 10) { - return { category: "Slightly long", color: "#8be9fd", severity: "info" }; + return { + category: "Slightly long", + color: "var(--info)", + severity: "info", + }; } return { category: "Far too long", - color: "#ff79c6", + color: "var(--chart-3)", severity: "warning", }; } @@ -220,23 +244,35 @@ export function classifyBodyFat( // hormonal / cardiac risk in chronic depletion). return { category: "Below essential", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } if (pct <= essentialMax) { - return { category: "Essential", color: "#f1fa8c", severity: "warning" }; + return { + category: "Essential", + color: "var(--dracula-yellow)", + severity: "warning", + }; } if (pct <= athleteMax) { - return { category: "Athletic", color: "#50fa7b", severity: "normal" }; + return { + category: "Athletic", + color: "var(--success)", + severity: "normal", + }; } if (pct <= fitnessMax) { - return { category: "Fitness", color: "#50fa7b", severity: "normal" }; + return { category: "Fitness", color: "var(--success)", severity: "normal" }; } if (pct <= acceptableMax) { - return { category: "Acceptable", color: "#ffb86c", severity: "warning" }; + return { + category: "Acceptable", + color: "var(--warning)", + severity: "warning", + }; } - return { category: "Obese", color: "#ff5555", severity: "danger" }; + return { category: "Obese", color: "var(--destructive)", severity: "danger" }; } // ── Activity Steps Classification ─────────────────────── @@ -259,24 +295,32 @@ export function classifySteps(steps: number): StepsClassification { if (steps < 3000) { return { category: "Very low", - color: "#ff5555", + color: "var(--destructive)", severity: "warning", }; } if (steps < 5000) { - return { category: "Low active", color: "#ffb86c", severity: "warning" }; + return { + category: "Low active", + color: "var(--warning)", + severity: "warning", + }; } if (steps < 7500) { return { category: "Moderately active", - color: "#f1fa8c", + color: "var(--dracula-yellow)", severity: "info", }; } if (steps < 10000) { - return { category: "Active", color: "#50fa7b", severity: "normal" }; + return { category: "Active", color: "var(--success)", severity: "normal" }; } - return { category: "Very active", color: "#50fa7b", severity: "normal" }; + return { + category: "Very active", + color: "var(--success)", + severity: "normal", + }; } /** diff --git a/src/lib/analytics/pulse-targets.ts b/src/lib/analytics/pulse-targets.ts index 58c2c6cda..4acc0ad56 100644 --- a/src/lib/analytics/pulse-targets.ts +++ b/src/lib/analytics/pulse-targets.ts @@ -110,30 +110,34 @@ export function classifyPulseByTarget( if (bpm < target.orangeMin) { return { category: "Significantly low", - color: "#8be9fd", + color: "var(--info)", severity: "info", }; } if (bpm < target.greenMin) { return { category: "Slightly low", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } if (bpm <= target.greenMax) { - return { category: "On target", color: "#50fa7b", severity: "normal" }; + return { + category: "On target", + color: "var(--success)", + severity: "normal", + }; } if (bpm <= target.orangeMax) { return { category: "Slightly elevated", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } return { category: "Significantly elevated", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 05c71c06a..38fe9fa2b 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -39,11 +39,21 @@ export function buildTrafficLightBands( const upperBound = options.upperBound ?? orangeHigh + Math.max(span, 1) * 2; const bands: ValueBand[] = [ - { min: lowerBound, max: orangeLow, color: "#ff5555", opacity: 0.16 }, - { min: orangeLow, max: min, color: "#ffb86c", opacity: 0.18 }, - { min, max, color: "#50fa7b", opacity: 0.2 }, - { min: max, max: orangeHigh, color: "#ffb86c", opacity: 0.18 }, - { min: orangeHigh, max: upperBound, color: "#ff5555", opacity: 0.16 }, + { + 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); @@ -88,26 +98,36 @@ export function buildWeightBandsFromHeight( const upperBound = options.upperBound ?? 250; return [ - { min: lowerBound, max: range.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: lowerBound, + max: range.orangeMin, + color: "var(--destructive)", + opacity: 0.16, + }, { min: range.orangeMin, max: range.greenMin, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, { min: range.greenMin, max: range.greenMax, - color: "#50fa7b", + color: "var(--success)", opacity: 0.2, }, { min: range.greenMax, max: range.orangeMax, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, - { min: range.orangeMax, max: upperBound, color: "#ff5555", opacity: 0.16 }, + { + min: range.orangeMax, + max: upperBound, + color: "var(--destructive)", + opacity: 0.16, + }, ].filter((band) => band.max > band.min); } diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index 56aa6d1ab..7f76f903a 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -58,26 +58,36 @@ function clientBands(profile: { orangeMax: pulseTarget.orangeMax, }, pulseBands: [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: 30, + max: pulseTarget.orangeMin, + color: "var(--destructive)", + opacity: 0.16, + }, { min: pulseTarget.orangeMin, max: pulseTarget.greenMin, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, { min: pulseTarget.greenMin, max: pulseTarget.greenMax, - color: "#50fa7b", + color: "var(--success)", opacity: 0.2, }, { min: pulseTarget.greenMax, max: pulseTarget.orangeMax, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMax, + max: 220, + color: "var(--destructive)", + opacity: 0.16, + }, ].filter((b) => b.max > b.min), bodyFatRange, bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index d7d7ad397..381ed55aa 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -88,26 +88,36 @@ export function buildDashboardBands(profile: { ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null; const pulseBands = [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: 30, + max: pulseTarget.orangeMin, + color: "var(--destructive)", + opacity: 0.16, + }, { min: pulseTarget.orangeMin, max: pulseTarget.greenMin, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, { min: pulseTarget.greenMin, max: pulseTarget.greenMax, - color: "#50fa7b", + color: "var(--success)", opacity: 0.2, }, { min: pulseTarget.greenMax, max: pulseTarget.orangeMax, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMax, + max: 220, + color: "var(--destructive)", + opacity: 0.16, + }, ].filter((band) => band.max > band.min); const bodyFatBands = buildTrafficLightBands( bodyFatRange.min, diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index cf0fe3c65..31e04a6aa 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -100,7 +100,7 @@ export const TREND_CHART_CONFIG: Record< metric: "bp", kind: "health-chart", types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], - colors: ["#ff79c6", "#8be9fd"], + colors: ["var(--chart-3)", "var(--chart-4)"], unit: "mmHg", yAxisUnit: "mmHg", titleKey: "charts.bloodPressure", @@ -112,7 +112,7 @@ export const TREND_CHART_CONFIG: Record< metric: "weight", kind: "health-chart", types: ["WEIGHT"], - colors: ["#bd93f9"], + colors: ["var(--chart-1)"], unit: "kg", titleKey: "charts.weight", captionKey: "insights.trendsRow.caption.weight", @@ -123,7 +123,7 @@ export const TREND_CHART_CONFIG: Record< metric: "pulse", kind: "health-chart", types: ["PULSE"], - colors: ["#50fa7b"], + colors: ["var(--chart-2)"], unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.pulse", @@ -144,7 +144,7 @@ export const TREND_CHART_CONFIG: Record< metric: "sleep", kind: "health-chart", types: ["SLEEP_DURATION"], - colors: ["#8be9fd"], + colors: ["var(--chart-4)"], unit: "h", yAxisUnit: "h", titleKey: "charts.sleep", @@ -155,7 +155,7 @@ export const TREND_CHART_CONFIG: Record< metric: "steps", kind: "health-chart", types: ["ACTIVITY_STEPS"], - colors: ["#50fa7b"], + colors: ["var(--chart-2)"], unit: "steps", yAxisUnit: "steps", titleKey: "charts.steps", @@ -166,7 +166,7 @@ export const TREND_CHART_CONFIG: Record< metric: "hrv", kind: "health-chart", types: ["HEART_RATE_VARIABILITY"], - colors: ["#bd93f9"], + colors: ["var(--chart-1)"], unit: "ms", yAxisUnit: "ms", titleKey: "charts.hrv", @@ -177,7 +177,7 @@ export const TREND_CHART_CONFIG: Record< metric: "resting_hr", kind: "health-chart", types: ["RESTING_HEART_RATE"], - colors: ["#ff79c6"], + colors: ["var(--chart-3)"], unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.restingHeartRate", @@ -188,7 +188,7 @@ export const TREND_CHART_CONFIG: Record< metric: "active_energy", kind: "health-chart", types: ["ACTIVE_ENERGY_BURNED"], - colors: ["#ffb86c"], + colors: ["var(--chart-5)"], unit: "kcal", yAxisUnit: "kcal", titleKey: "charts.activeEnergy", @@ -199,7 +199,7 @@ export const TREND_CHART_CONFIG: Record< metric: "flights", kind: "health-chart", types: ["FLIGHTS_CLIMBED"], - colors: ["#f1fa8c"], + colors: ["var(--dracula-yellow)"], unit: "flights", yAxisUnit: "flights", titleKey: "charts.flights", @@ -210,7 +210,7 @@ export const TREND_CHART_CONFIG: Record< metric: "distance", kind: "health-chart", types: ["WALKING_RUNNING_DISTANCE"], - colors: ["#8be9fd"], + colors: ["var(--chart-4)"], unit: "km", yAxisUnit: "km", titleKey: "charts.distance", @@ -221,7 +221,7 @@ export const TREND_CHART_CONFIG: Record< metric: "vo2_max", kind: "health-chart", types: ["VO2_MAX"], - colors: ["#50fa7b"], + colors: ["var(--chart-2)"], unit: "mL/kg·min", yAxisUnit: "mL/kg·min", titleKey: "charts.vo2Max", @@ -232,7 +232,7 @@ export const TREND_CHART_CONFIG: Record< metric: "body_temp", kind: "health-chart", types: ["BODY_TEMPERATURE"], - colors: ["#ff5555"], + colors: ["var(--destructive)"], unit: "°C", yAxisUnit: "°C", titleKey: "charts.bodyTemperature", diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 8f17aa48b..788b7e7c3 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -171,7 +171,11 @@ export const SIGNALS: Record = { archetype: "physiological-vital", normalRange: { low: 50, high: 100 }, referenceMetric: "RESTING_HEART_RATE", - chart: { overlayKey: "restingHr", color: "#ff5555", yAxisUnit: "bpm" }, + chart: { + overlayKey: "restingHr", + color: "var(--destructive)", + yAxisUnit: "bpm", + }, surfaces: { detailPage: true, correlationEligible: false, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 89b728e66..c00ffc3ee 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -828,10 +828,10 @@ export async function buildTargetsResponse(user: AuthedUser) { classification: bpInTargetRate != null ? bpInTargetRate >= 70 - ? { category: "Good", color: "#50fa7b" } + ? { category: "Good", color: "var(--success)" } : bpInTargetRate >= 40 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } + ? { category: "Moderate", color: "var(--dracula-yellow)" } + : { category: "Low", color: "var(--destructive)" } : null, source: "ESH 2023", ...inTargetConsistency, @@ -1233,10 +1233,10 @@ export async function buildTargetsResponse(user: AuthedUser) { classification: complianceRate7 != null ? complianceRate7 >= 90 - ? { category: "Very good", color: "#50fa7b" } + ? { category: "Very good", color: "var(--success)" } : complianceRate7 >= 70 - ? { category: "Good", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } + ? { category: "Good", color: "var(--dracula-yellow)" } + : { category: "Low", color: "var(--destructive)" } : null, source: "7-day", details: { @@ -1358,10 +1358,10 @@ export async function buildTargetsResponse(user: AuthedUser) { const moodClassification = latestMoodScore != null ? latestMoodScore >= 3.5 - ? { category: "Good", color: "#50fa7b" } + ? { category: "Good", color: "var(--success)" } : latestMoodScore >= 2 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } + ? { category: "Moderate", color: "var(--dracula-yellow)" } + : { category: "Low", color: "var(--destructive)" } : null; const moodRange = { min: 3.5, max: 5 }; @@ -1397,10 +1397,10 @@ export async function buildTargetsResponse(user: AuthedUser) { const stabilityClassification = stdDev <= 0.5 - ? { category: "Very stable", color: "#50fa7b" } + ? { category: "Very stable", color: "var(--success)" } : stdDev <= 1.0 - ? { category: "Stable", color: "#f1fa8c" } - : { category: "Fluctuating", color: "#ff5555" }; + ? { category: "Stable", color: "var(--dracula-yellow)" } + : { category: "Fluctuating", color: "var(--destructive)" }; // Mood stability is computed from the same recent-mood window; // consistency mirrors MOOD_SCORE so the strip aligns with the @@ -1476,11 +1476,14 @@ export async function buildTargetsResponse(user: AuthedUser) { let classification: { category: string; color: string } | null = null; if (range && effRange) { if (latest >= range.min && latest <= range.max) { - classification = { category: "Optimal", color: "#50fa7b" }; + classification = { category: "Optimal", color: "var(--success)" }; } else if (latest >= effRange.orangeMin && latest <= effRange.orangeMax) { - classification = { category: "Elevated", color: "#f1fa8c" }; + classification = { + category: "Elevated", + color: "var(--dracula-yellow)", + }; } else { - classification = { category: "High", color: "#ff5555" }; + classification = { category: "High", color: "var(--destructive)" }; } }