diff --git a/frontend/src/components/ui/SportIcon.jsx b/frontend/src/components/ui/SportIcon.jsx new file mode 100644 index 0000000..c290974 --- /dev/null +++ b/frontend/src/components/ui/SportIcon.jsx @@ -0,0 +1,70 @@ +// Crisp, stroke-based activity icons β€” replace the old emoji glyphs so sports are +// easy to tell apart at any resolution. Renders an inline using `currentColor` +// (or an explicit `color`), so it works both in normal JSX and nested inside another +// SVG (e.g. recharts overlays) by passing `x`/`y`/`size`. +// +// viewBox is 0 0 24 24, strokeWidth 2, round caps/joins β€” a single coherent line set. + +const ICONS = { + // Runner mid-stride + running: ( + <> + + + + + + ), + // Bicycle + cycling: ( + <> + + + + + + ), + // Mountain (hiking) + hiking: ( + <> + + + ), + // Walker + walking: ( + <> + + + + + + ), + // Waves (swimming) + swimming: ( + <> + + + + + ), + // Activity pulse (catch-all) + other: ( + <> + + + ), +} + +export default function SportIcon({ sport, size = 20, color = 'currentColor', x, y, className, style }) { + const paths = ICONS[(sport || 'other').toLowerCase()] || ICONS.other + return ( + + {paths} + + ) +} diff --git a/frontend/src/components/ui/StatCard.jsx b/frontend/src/components/ui/StatCard.jsx index 973c0ac..1a00157 100644 --- a/frontend/src/components/ui/StatCard.jsx +++ b/frontend/src/components/ui/StatCard.jsx @@ -5,13 +5,17 @@ const accentColors = { green: 'text-green-400', orange: 'text-orange-400', purple: 'text-purple-400', + violet: 'text-violet-400', } -export default function StatCard({ label, value, accent = 'default', sub }) { +// `color` (a hex) overrides the named `accent` β€” used where the colour is dynamic +// (e.g. VO2 max, coloured by its current rating category). +export default function StatCard({ label, value, accent = 'default', color, sub }) { return (

{label}

-

{value}

+

{value}

{sub &&

{sub}

}
) diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx index c557fac..a844d3f 100644 --- a/frontend/src/pages/ActivitiesPage.jsx +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -5,9 +5,10 @@ import { format } from 'date-fns' import api from '../utils/api' import { formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation, - formatDate, sportIcon, sportColor, convertKm, distanceUnitLabel, + formatDate, sportColor, convertKm, distanceUnitLabel, } from '../utils/format' import { useUnit } from '../hooks/useUnits' +import SportIcon from '../components/ui/SportIcon' const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking'] @@ -59,10 +60,16 @@ export default function ActivitiesPage() { {ytdStats && (
{ytdStats.running_km > 0 && ( - πŸƒ {convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year + + + {convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year + )} {ytdStats.cycling_km > 0 && ( - 🚴 {convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year + + + {convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year + )}
)} @@ -83,13 +90,14 @@ export default function ActivitiesPage() { ))} @@ -107,10 +115,10 @@ export default function ActivitiesPage() { > {/* Sport indicator */}
- {sportIcon(activity.sport_type)} +
{/* Name + date */} @@ -160,7 +168,7 @@ export default function ActivitiesPage() { {activities?.length === 0 && (
-

πŸƒ

+

No activities yet

Import your Garmin or Strava data to get started diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index a6a11d5..29b058f 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -11,14 +11,15 @@ import RouteLeaderboard from '../components/activity/RouteLeaderboard' import StatCard from '../components/ui/StatCard' import { formatDuration, formatDistance, formatPace, formatElevation, - formatHeartRate, formatDateTime, formatCadence, sportIcon, + formatHeartRate, formatDateTime, formatCadence, sportColor, } from '../utils/format' import { useUnit } from '../hooks/useUnits' +import SportIcon from '../components/ui/SportIcon' import { projectToTrack } from '../utils/track' const METRICS = [ - { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, + { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#ef4444' }, { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, { key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' }, { key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' }, @@ -156,7 +157,7 @@ export default function ActivityDetailPage() {

- {sportIcon(activity.sport_type)} + {editingName ? ( h.steps != null ? h.steps.toLocaleString() : '--' }, stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) }, - stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) }, + stat_sleep: { label: 'Sleep', accent: 'violet', val: h => formatSleep(h.sleep_duration_s) }, stat_vo2max: { label: 'VOβ‚‚ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined }, stat_hrv: { label: 'HRV status', accent: 'purple', val: h => (h.hrv_weekly_avg ?? h.hrv_nightly_avg) != null ? `${Math.round(h.hrv_weekly_avg ?? h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ? : undefined }, - stat_running: { label: 'Running this year', accent: 'blue', val: (h, y, u) => y ? `${convertKm(y.running_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' }, + stat_running: { label: 'Running this year', accent: 'green', val: (h, y, u) => y ? `${convertKm(y.running_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' }, stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y, u) => y ? `${convertKm(y.cycling_km, u).toFixed(0)} ${distanceUnitLabel(u)}` : '--' }, - stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' }, + stat_stress: { label: 'Stress', accent: 'orange', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' }, stat_calories: { label: 'Active calories', accent: 'orange', val: h => h.active_calories != null ? Math.round(h.active_calories).toLocaleString() : '--' }, stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' }, } @@ -106,14 +108,14 @@ function Stat({ label, value }) { // ── Chart widgets ──────────────────────────────────────────────────────────── // Sport icon centred inside the activity band that sits below the battery bars. -function BBActivityRefLabel({ viewBox, icon }) { +function BBActivityRefLabel({ viewBox, sport }) { if (!viewBox) return null const { x, y, width = 0, height = 0 } = viewBox + const size = 13 return ( - - {icon} - + ) } @@ -185,7 +187,7 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) { return ( } /> + label={} /> ) })} @@ -228,24 +230,25 @@ function Sparkline({ data, dataKey, color, gradId, fmt }) { ) } -function Vo2MaxTrend({ health, recentHealth }) { +function Vo2MaxTrend({ health, recentHealth, profile }) { const series = useMemo( () => [...(recentHealth || [])].filter(d => d.vo2max != null) .sort((a, b) => new Date(a.date) - new Date(b.date)) .map(d => ({ date: d.date, v: d.vo2max })), [recentHealth], ) + const color = vo2Color(health.vo2max, profile?.birth_year, profile?.biological_sex) return (
- {health.vo2max != null ? health.vo2max.toFixed(1) : '--'} + {health.vo2max != null ? health.vo2max.toFixed(1) : '--'} ml/kg/min
{health.fitness_age != null &&

Fitness age {health.fitness_age}

}
{series.length >= 2 - ? v.toFixed(1)} /> + ? v.toFixed(1)} /> :

Not enough history

}
@@ -265,12 +268,12 @@ function WeightMini({ recentHealth }) {
- {latest != null ? latest.toFixed(1) : '--'} + {latest != null ? latest.toFixed(1) : '--'} kg
{series.length >= 2 - ? `${v.toFixed(1)} kg`} /> + ? `${v.toFixed(1)} kg`} /> :

Not enough history

}
@@ -278,11 +281,12 @@ function WeightMini({ recentHealth }) { ) } +// Canonical sleep-stage palette β€” matches the Health page hypnogram/charts. const SLEEP_STAGES = [ - { key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' }, - { key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' }, - { key: 'sleep_light_s', label: 'Light', color: '#60a5fa' }, - { key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' }, + { key: 'sleep_deep_s', label: 'Deep', color: '#6366f1' }, + { key: 'sleep_rem_s', label: 'REM', color: '#7c3aed' }, + { key: 'sleep_light_s', label: 'Light', color: '#a78bfa' }, + { key: 'sleep_awake_s', label: 'Awake', color: '#eab308' }, ] function SleepDetail({ health, sleepStages }) { @@ -292,7 +296,7 @@ function SleepDetail({ health, sleepStages }) {
- {formatSleep(health.sleep_duration_s)} + {formatSleep(health.sleep_duration_s)} {health.sleep_score != null && ( score {Math.round(health.sleep_score)} )} @@ -405,7 +409,7 @@ function FeaturedActivity({ activity, segments }) {
- {sportIcon(activity.sport_type)} +
{activity.name}

{formatDate(activity.start_time)}

@@ -467,7 +471,7 @@ function RecentActivities({ activities }) { {activities.slice(0, 6).map(activity => ( - {sportIcon(activity.sport_type)} +

{activity.name}

{activity.named_route_name && ( @@ -632,13 +636,17 @@ export default function DashboardPage() { const renderWidget = (id) => { if (STAT_DEFS[id]) { const d = STAT_DEFS[id] - return } switch (id) { case 'weekly': return case 'bodyBattery': return - case 'vo2maxTrend': return + case 'vo2maxTrend': return case 'sleepDetail': return case 'weight': return case 'featured': return diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index a78bdcc..303428c 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -6,9 +6,11 @@ import { } from 'recharts' import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns' import api from '../utils/api' -import { formatSleep, sportIcon, sportColor } from '../utils/format' +import { formatSleep, sportColor } from '../utils/format' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' import HrvBadge from '../components/ui/HrvBadge' +import SportIcon from '../components/ui/SportIcon' +import { VO2_CATEGORIES, getVo2Category, vo2Thresholds, vo2Color } from '../utils/vo2' import SleepHypnogram from '../components/health/SleepHypnogram' const RANGES = [ @@ -24,42 +26,6 @@ const RANGES = [ // ── VO2 Max gauge ──────────────────────────────────────────────────────────── -// Garmin/Cooper Institute VO2 max thresholds -// [maxAge, [fair_min, good_min, excellent_min, superior_min]] -// value < fair_min β†’ Poor; >= superior_min β†’ Superior -const VO2_MALE = [ - [29, [41.7, 45.4, 51.1, 55.4]], - [39, [40.5, 44.0, 48.3, 54.0]], - [49, [38.5, 42.4, 46.4, 52.5]], - [59, [35.6, 39.2, 43.4, 48.9]], - [69, [32.3, 35.5, 39.5, 45.7]], - [Infinity, [29.4, 32.3, 36.7, 42.1]], -] -const VO2_FEMALE = [ - [29, [36.1, 39.5, 43.9, 49.6]], - [39, [34.4, 37.8, 42.4, 47.4]], - [49, [33.0, 36.3, 39.7, 45.3]], - [59, [30.1, 33.0, 36.7, 41.1]], - [69, [27.5, 30.0, 33.0, 37.8]], - [Infinity, [25.9, 28.1, 30.9, 36.7]], -] -const VO2_CATEGORIES = [ - { label: 'Poor', color: '#ef4444' }, - { label: 'Fair', color: '#f97316' }, - { label: 'Good', color: '#22c55e' }, - { label: 'Excellent', color: '#3b82f6' }, - { label: 'Superior', color: '#a855f7' }, -] - -function getVo2Category(value, age, sex) { - const table = sex === 'female' ? VO2_FEMALE : VO2_MALE - const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] - const thresholds = row[1] - // thresholds are lower-bounds: count how many the value meets or exceeds - const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0) - return VO2_CATEGORIES[idx] -} - function Vo2MaxGauge({ value, birthYear, biologicalSex }) { const MIN = 30, MAX = 70 // cx/cy = centre of the semicircle; arc goes leftβ†’topβ†’right (sweep=1, clockwise in SVG) @@ -85,9 +51,7 @@ function Vo2MaxGauge({ value, birthYear, biologicalSex }) { } // ACSM category boundaries for this user's age/sex - const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE - const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] - const thresholds = row[1] + const thresholds = vo2Thresholds(age, biologicalSex) const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands const cat = value != null ? getVo2Category(value, age, biologicalSex) : null @@ -163,8 +127,8 @@ function IntradayHrChart({ values }) { - - + + format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)} bpm`, 'HR']} /> - @@ -184,14 +148,14 @@ function IntradayHrChart({ values }) { // ── Body Battery ───────────────────────────────────────────────────────────── -function ActivityRefLabel({ viewBox, icon }) { +function ActivityRefLabel({ viewBox, sport }) { if (!viewBox) return null const { x, y, width = 0, height = 0 } = viewBox + const size = 14 return ( - - {icon} - + ) } @@ -277,7 +241,7 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) return ( } /> + label={} /> ) })} @@ -348,10 +312,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb : day.avg_stress < 25 ? 'Restful' : day.avg_stress < 50 ? 'Low' : day.avg_stress < 75 ? 'Medium' : 'High' - const stressColor = !day.avg_stress ? 'text-white' - : day.avg_stress < 25 ? 'text-green-400' - : day.avg_stress < 50 ? 'text-yellow-400' - : day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400' + const stressColor = day.avg_stress ? 'text-orange-400' : 'text-white' return (
@@ -373,13 +334,13 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb

Sleep

{day.sleep_score != null && ( - + Score {Math.round(day.sleep_score)} )}
- + {formatSleep(day.sleep_duration_s)} {day.sleep_start && day.sleep_end && ( @@ -428,7 +389,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb

Resting HR

- + {day.resting_hr ? Math.round(day.resting_hr) : '--'} bpm @@ -457,7 +418,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb

Avg HR (day)

- + {day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'} {day.max_hr_day && / {Math.round(day.max_hr_day)} max} @@ -466,7 +427,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb

Weight

- + {snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'} {snapshotWeight && kg} @@ -799,8 +760,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) { }}> - - + + @@ -817,8 +778,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) { )} - @@ -916,6 +877,12 @@ export default function HealthPage() { return found ? found.vo2max : null }, [allDaysSorted]) + // Colour VO2 max (gauge + trend) by the current rating's category. + const vo2TrendColor = useMemo( + () => vo2Color(latestVo2max, profile?.birth_year, profile?.biological_sex), + [latestVo2max, profile], + ) + // Weight for the snapshot: the selected day's, or the most recent earlier reading. const snapshotWeight = useMemo(() => { if (!selectedDay) return null @@ -1013,7 +980,7 @@ export default function HealthPage() {

Resting Heart Rate

- Math.round(v)} domain={[0, 200]} selectedDate={selDateForCharts} onDayClick={handleDayClick} /> @@ -1103,7 +1070,7 @@ export default function HealthPage() {

Stress Level

- Math.round(v)} domain={[0, 100]} selectedDate={selDateForCharts} onDayClick={handleDayClick} /> @@ -1111,7 +1078,7 @@ export default function HealthPage() {

Heart Rate

- Math.round(v)} domain={[0, 200]} selectedDate={selDateForCharts} onDayClick={handleDayClick} /> @@ -1131,7 +1098,7 @@ export default function HealthPage() { {metrics.some(d => d.vo2max) && (

VO2 Max

- v.toFixed(1)} domain={vo2Domain} connectNulls showDots diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index e9ac3f9..0dfa2a0 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -243,7 +243,7 @@ export default function ProfilePage() { {healthSummary?.latest?.weight_kg && (

Current weight (from Garmin)

- {healthSummary.latest.weight_kg.toFixed(1)} kg + {healthSummary.latest.weight_kg.toFixed(1)} kg
)}
diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index adbb651..9854c08 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import ActivityMap from '../components/activity/ActivityMap' -import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' +import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format' import { useUnit } from '../hooks/useUnits' // Decode Google encoded polyline to [[lat,lng], ...] @@ -300,7 +300,7 @@ export default function RoutesPage() { {recentActivities?.map(a => ( ))} diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index c14b1c5..4190581 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -105,18 +105,26 @@ export function hrZoneColor(zone) { return colors[zone] || '#9ca3af' } -export function sportIcon(sportType) { - const icons = { - running: 'πŸƒ', cycling: '🚴', hiking: 'πŸ₯Ύ', - walking: '🚢', other: '⚑', - } - return icons[sportType?.toLowerCase()] || '⚑' +// Standard metric colours, used everywhere a metric is charted or shown as a stat +// so the whole platform stays consistent. (Sleep keeps its per-stage graph colours; +// SLEEP here is the light-sleep violet used for sleep *stat* text. VO2 max is the +// only metric coloured dynamically β€” by its gauge category β€” so it lives elsewhere.) +export const METRIC_COLOR = { + HEART_RATE: '#ef4444', // red + SLEEP: '#a78bfa', // light-sleep violet (stat text) + STRESS: '#f97316', // orange + STEPS: '#fbbf24', // yellow + WEIGHT: '#3b82f6', // blue } export function sportColor(sportType) { const colors = { - running: '#3b82f6', cycling: '#f97316', - hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280', + running: '#22c55e', // green + cycling: '#f97316', // orange + hiking: '#84cc16', // lime + walking: '#2dd4bf', // teal + swimming:'#38bdf8', // sky + other: '#9ca3af', // gray } - return colors[sportType?.toLowerCase()] || '#6b7280' + return colors[sportType?.toLowerCase()] || '#9ca3af' } diff --git a/frontend/src/utils/vo2.js b/frontend/src/utils/vo2.js new file mode 100644 index 0000000..3b037b1 --- /dev/null +++ b/frontend/src/utils/vo2.js @@ -0,0 +1,53 @@ +// VO2 max rating categories (Garmin / Cooper Institute thresholds), shared by the +// Health page gauge/trend and the Dashboard stat + mini-widget so VO2 max is always +// coloured by its current rating. + +// [maxAge, [fair_min, good_min, excellent_min, superior_min]] +// value < fair_min β†’ Poor; >= superior_min β†’ Superior +const VO2_MALE = [ + [29, [41.7, 45.4, 51.1, 55.4]], + [39, [40.5, 44.0, 48.3, 54.0]], + [49, [38.5, 42.4, 46.4, 52.5]], + [59, [35.6, 39.2, 43.4, 48.9]], + [69, [32.3, 35.5, 39.5, 45.7]], + [Infinity, [29.4, 32.3, 36.7, 42.1]], +] +const VO2_FEMALE = [ + [29, [36.1, 39.5, 43.9, 49.6]], + [39, [34.4, 37.8, 42.4, 47.4]], + [49, [33.0, 36.3, 39.7, 45.3]], + [59, [30.1, 33.0, 36.7, 41.1]], + [69, [27.5, 30.0, 33.0, 37.8]], + [Infinity, [25.9, 28.1, 30.9, 36.7]], +] + +export const VO2_CATEGORIES = [ + { label: 'Poor', color: '#ef4444' }, + { label: 'Fair', color: '#f97316' }, + { label: 'Good', color: '#22c55e' }, + { label: 'Excellent', color: '#3b82f6' }, + { label: 'Superior', color: '#a855f7' }, +] + +// Age/sex category boundary table (6 boundary values around the 5 colour bands). +export function vo2Thresholds(age, sex) { + const table = sex === 'female' ? VO2_FEMALE : VO2_MALE + const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] + return row[1] +} + +export function getVo2Category(value, age, sex) { + const thresholds = vo2Thresholds(age, sex) + // thresholds are lower-bounds: count how many the value meets or exceeds + const idx = thresholds.reduce((n, t) => (value >= t ? n + 1 : n), 0) + return VO2_CATEGORIES[idx] +} + +const ageFromBirthYear = (birthYear) => + birthYear ? new Date().getFullYear() - birthYear : 40 + +// Convenience: the rating colour for a VO2 value given the user's profile. +export function vo2Color(value, birthYear, sex, fallback = '#3b82f6') { + if (value == null) return fallback + return getVo2Category(value, ageFromBirthYear(birthYear), sex)?.color || fallback +}