frontend: standardise metric colours + crisp activity icons platform-wide
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s

Colours (Health, Dashboard, Activities, ActivityDetail, Profile):
- Heart rate (resting/avg/24h/intraday + activity HR metric) → red
- Sleep stat text → light-sleep violet; unify dashboard sleep-stage palette
  to the Health-page hypnogram colours
- VO2 max graph + stat coloured by its gauge rating category (shared utils/vo2.js)
- Stress → orange; Steps stay yellow; Weight → blue
- Running → green, Cycling → orange (sportColor)

Icons: new stroke-based SVG SportIcon component replaces emoji everywhere
(lists, filters, activity detail, body-battery overlays, YTD stats) — crisp
at any resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:31:23 +01:00
co-authored by Claude Opus 4.8
parent a0912fd09a
commit 0e1d35364f
10 changed files with 234 additions and 115 deletions
+70
View File
@@ -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 <svg> 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: (
<>
<circle cx="13" cy="4" r="1.6" />
<path d="M4 17l5 1l.75 -1.5" />
<path d="M15 21v-4l-4 -3l1 -6" />
<path d="M7 12v-3l5 -1l3 3l3 1" />
</>
),
// Bicycle
cycling: (
<>
<circle cx="5.5" cy="17.5" r="3.5" />
<circle cx="18.5" cy="17.5" r="3.5" />
<circle cx="15" cy="5" r="1" />
<path d="M12 17.5V14l-3 -3l4 -3l2 3h2" />
</>
),
// Mountain (hiking)
hiking: (
<>
<path d="M3 20h18l-7 -13l-3.5 6.5l-2 -2.5z" />
</>
),
// Walker
walking: (
<>
<circle cx="13" cy="4" r="1.6" />
<path d="M7 21l3 -4" />
<path d="M16 21l-2 -4l-3 -3l1 -6" />
<path d="M6 12l2 -3l4 -1l3 3l3 1" />
</>
),
// Waves (swimming)
swimming: (
<>
<path d="M2 6c.6 .5 1.2 1 2.5 1c2.5 0 2.5 -2 5 -2c2.6 0 2.4 2 5 2c2.5 0 2.5 -2 5 -2c1.3 0 1.9 .5 2.5 1" />
<path d="M2 12c.6 .5 1.2 1 2.5 1c2.5 0 2.5 -2 5 -2c2.6 0 2.4 2 5 2c2.5 0 2.5 -2 5 -2c1.3 0 1.9 .5 2.5 1" />
<path d="M2 18c.6 .5 1.2 1 2.5 1c2.5 0 2.5 -2 5 -2c2.6 0 2.4 2 5 2c2.5 0 2.5 -2 5 -2c1.3 0 1.9 .5 2.5 1" />
</>
),
// Activity pulse (catch-all)
other: (
<>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</>
),
}
export default function SportIcon({ sport, size = 20, color = 'currentColor', x, y, className, style }) {
const paths = ICONS[(sport || 'other').toLowerCase()] || ICONS.other
return (
<svg
x={x} y={y} width={size} height={size}
viewBox="0 0 24 24" fill="none" stroke={color}
strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"
className={className} style={style}
>
{paths}
</svg>
)
}
+6 -2
View File
@@ -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 (
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
<p className={`text-lg font-semibold ${color ? '' : accentColors[accent]}`}
style={color ? { color } : undefined}>{value}</p>
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
</div>
)
+16 -8
View File
@@ -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 && (
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
{ytdStats.running_km > 0 && (
<span className="text-blue-400">🏃 {convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year</span>
<span className="inline-flex items-center gap-1.5" style={{ color: sportColor('running') }}>
<SportIcon sport="running" size={15} color="currentColor" />
{convertKm(ytdStats.running_km, unit).toFixed(0)} {distLabel} this year
</span>
)}
{ytdStats.cycling_km > 0 && (
<span className="text-orange-400">🚴 {convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year</span>
<span className="inline-flex items-center gap-1.5" style={{ color: sportColor('cycling') }}>
<SportIcon sport="cycling" size={15} color="currentColor" />
{convertKm(ytdStats.cycling_km, unit).toFixed(0)} {distLabel} this year
</span>
)}
</div>
)}
@@ -83,13 +90,14 @@ export default function ActivitiesPage() {
<button
key={s}
onClick={() => { setSport(s); setPage(1) }}
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors ${
className={`capitalize text-sm px-3 py-1.5 rounded-full border transition-colors inline-flex items-center gap-1.5 ${
sport === s
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
}`}
>
{s === 'all' ? 'All' : `${sportIcon(s)} ${s}`}
{s !== 'all' && <SportIcon sport={s} size={15} color="currentColor" />}
{s === 'all' ? 'All' : s}
</button>
))}
</div>
@@ -107,10 +115,10 @@ export default function ActivitiesPage() {
>
{/* Sport indicator */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 text-lg"
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
>
{sportIcon(activity.sport_type)}
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} />
</div>
{/* Name + date */}
@@ -160,7 +168,7 @@ export default function ActivitiesPage() {
{activities?.length === 0 && (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">🏃</p>
<SportIcon sport="running" size={44} color="currentColor" className="mx-auto mb-3" />
<p className="text-lg">No activities yet</p>
<p className="text-sm mt-1">
<Link to="/upload" className="text-blue-400 hover:underline">Import your Garmin or Strava data</Link> to get started
+4 -3
View File
@@ -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() {
<div className="flex items-start justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-2xl">{sportIcon(activity.sport_type)}</span>
<SportIcon sport={activity.sport_type} size={26} color={sportColor(activity.sport_type)} className="shrink-0" />
{editingName ? (
<input
autoFocus
+32 -24
View File
@@ -16,9 +16,11 @@ import SleepHypnogram from '../components/health/SleepHypnogram'
import ActivityMap from '../components/activity/ActivityMap'
import {
formatDuration, formatDistance, formatHeartRate, formatElevation,
formatDate, sportIcon, sportColor, formatSleep, convertKm, distanceUnitLabel,
formatDate, sportColor, formatSleep, convertKm, distanceUnitLabel,
} from '../utils/format'
import { useUnit } from '../hooks/useUnits'
import SportIcon from '../components/ui/SportIcon'
import { vo2Color } from '../utils/vo2'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const Grid = WidthProvider(GridLayout)
@@ -30,12 +32,12 @@ const tooltipStyle = { background: '#111827', border: '1px solid #374151', borde
const STAT_DEFS = {
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => 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 ? <HrvBadge status={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 (
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="central"
fontSize={13} style={{ pointerEvents: 'none' }}>
{icon}
</text>
<SportIcon sport={sport} size={size} color="#fff"
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
style={{ pointerEvents: 'none' }} />
)
}
@@ -185,7 +187,7 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) {
return (
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={BB_ACTIVITY_BAND_BOTTOM}
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
label={<BBActivityRefLabel icon={sportIcon(a.sport_type)} />} />
label={<BBActivityRefLabel sport={a.sport_type} />} />
)
})}
</BarChart>
@@ -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 (
<Card title="VO₂ Max" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-blue-400">{health.vo2max != null ? health.vo2max.toFixed(1) : '--'}</span>
<span className="text-3xl font-bold" style={{ color }}>{health.vo2max != null ? health.vo2max.toFixed(1) : '--'}</span>
<span className="text-xs text-gray-500">ml/kg/min</span>
</div>
{health.fitness_age != null && <p className="text-xs text-gray-500 mt-0.5">Fitness age {health.fitness_age}</p>}
<div className="flex-1 min-h-0 mt-2">
{series.length >= 2
? <Sparkline data={series} dataKey="v" color="#3b82f6" gradId="grad-dash-vo2" fmt={v => v.toFixed(1)} />
? <Sparkline data={series} dataKey="v" color={color} gradId="grad-dash-vo2" fmt={v => v.toFixed(1)} />
: <p className="text-xs text-gray-600">Not enough history</p>}
</div>
</div>
@@ -265,12 +268,12 @@ function WeightMini({ recentHealth }) {
<Card title="Weight" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-emerald-300">{latest != null ? latest.toFixed(1) : '--'}</span>
<span className="text-3xl font-bold text-blue-400">{latest != null ? latest.toFixed(1) : '--'}</span>
<span className="text-xs text-gray-500">kg</span>
</div>
<div className="flex-1 min-h-0 mt-2">
{series.length >= 2
? <Sparkline data={series} dataKey="w" color="#34d399" gradId="grad-dash-weight" fmt={v => `${v.toFixed(1)} kg`} />
? <Sparkline data={series} dataKey="w" color="#3b82f6" gradId="grad-dash-weight" fmt={v => `${v.toFixed(1)} kg`} />
: <p className="text-xs text-gray-600">Not enough history</p>}
</div>
</div>
@@ -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 }) {
<Card title="Sleep" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-3 flex-wrap">
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
<span className="text-3xl font-bold text-violet-400">{formatSleep(health.sleep_duration_s)}</span>
{health.sleep_score != null && (
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
)}
@@ -405,7 +409,7 @@ function FeaturedActivity({ activity, segments }) {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl">{sportIcon(activity.sport_type)}</span>
<SportIcon sport={activity.sport_type} size={22} color={sportColor(activity.sport_type)} className="shrink-0" />
<div className="min-w-0">
<Link to={`/activities/${activity.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">{activity.name}</Link>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
@@ -467,7 +471,7 @@ function RecentActivities({ activities }) {
{activities.slice(0, 6).map(activity => (
<Link key={activity.id} to={`/activities/${activity.id}`}
className="flex items-center gap-3 flex-1 min-h-0 overflow-hidden px-2 border-b border-gray-800/50 last:border-0 hover:bg-gray-800/30 rounded-lg transition-colors">
<span className="text-lg shrink-0">{sportIcon(activity.sport_type)}</span>
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} className="shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
{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 <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats, unit)}
// VO2 max is coloured dynamically by its current rating category.
const color = id === 'stat_vo2max'
? vo2Color(health.vo2max, profile?.birth_year, profile?.biological_sex)
: undefined
return <StatCard label={d.label} accent={d.accent} color={color} value={d.val(health, ytdStats, unit)}
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
}
switch (id) {
case 'weekly': return <WeeklyChart activities={allActivities} />
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} activities={allActivities} />
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} profile={profile} />
case 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
case 'weight': return <WeightMini recentHealth={recentHealth} />
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
+33 -66
View File
@@ -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 }) {
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
@@ -175,7 +139,7 @@ function IntradayHrChart({ values }) {
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
<Area type="monotone" dataKey="hr" stroke="#f43f5e" strokeWidth={1.5}
<Area type="monotone" dataKey="hr" stroke="#ef4444" strokeWidth={1.5}
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
</AreaChart>
</ResponsiveContainer>
@@ -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 (
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="central"
fontSize={13} style={{ pointerEvents: 'none' }}>
{icon}
</text>
<SportIcon sport={sport} size={size} color="#fff"
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
style={{ pointerEvents: 'none' }} />
)
}
@@ -277,7 +241,7 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
return (
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />} />
label={<ActivityRefLabel sport={a.sport_type} />} />
)
})}
</BarChart>
@@ -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 (
<div className="space-y-4">
@@ -373,13 +334,13 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
{day.sleep_score != null && (
<span className="text-xs px-2 py-0.5 rounded-full border border-indigo-400/30 bg-indigo-400/10 text-indigo-300">
<span className="text-xs px-2 py-0.5 rounded-full border border-violet-400/30 bg-violet-400/10 text-violet-300">
Score {Math.round(day.sleep_score)}
</span>
)}
</div>
<div className="flex flex-wrap items-end gap-3">
<span className="text-4xl font-bold text-white tracking-tight">
<span className="text-4xl font-bold text-violet-400 tracking-tight">
{formatSleep(day.sleep_duration_s)}
</span>
{day.sleep_start && day.sleep_end && (
@@ -428,7 +389,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
<div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-bold text-rose-400">
<span className="text-3xl font-bold text-red-400">
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
@@ -457,7 +418,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
<div>
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
<div className="flex items-baseline gap-1.5">
<span className="text-xl font-semibold text-orange-400">
<span className="text-xl font-semibold text-red-400">
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
</span>
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
@@ -466,7 +427,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
<div>
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-semibold text-emerald-400">
<span className="text-xl font-semibold text-blue-400">
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
</span>
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
@@ -799,8 +760,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
}}>
<defs>
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
@@ -817,8 +778,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
)}
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
<Area type="monotone" dataKey="w" stroke="#3b82f6" strokeWidth={2}
fill="url(#grad-weight)" dot={{ fill: '#3b82f6', r: 3, strokeWidth: 0 }}
connectNulls isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
@@ -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() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
<MetricChart data={metrics} dataKey="resting_hr" color="#ef4444"
formatter={v => Math.round(v)}
domain={[0, 200]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
@@ -1103,7 +1070,7 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
<MetricChart data={metrics} dataKey="avg_stress" color="#f97316"
formatter={v => Math.round(v)}
domain={[0, 100]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
@@ -1111,7 +1078,7 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
<MetricChart data={metrics} dataKey="avg_hr_day" color="#ef4444"
formatter={v => Math.round(v)}
domain={[0, 200]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
@@ -1131,7 +1098,7 @@ export default function HealthPage() {
{metrics.some(d => d.vo2max) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
<MetricChart data={metrics} dataKey="vo2max" color={vo2TrendColor}
formatter={v => v.toFixed(1)}
domain={vo2Domain}
connectNulls showDots
+1 -1
View File
@@ -243,7 +243,7 @@ export default function ProfilePage() {
{healthSummary?.latest?.weight_kg && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
<span className="text-lg font-semibold text-blue-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
</div>
)}
</div>
+2 -2
View File
@@ -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() {
<option value="">Select an activity</option>
{recentActivities?.map(a => (
<option key={a.id} value={a.id}>
{sportIcon(a.sport_type)} {a.name} {formatDistance(a.distance_m, unit)} on {formatDate(a.start_time)}
{a.name} {formatDistance(a.distance_m, unit)} on {formatDate(a.start_time)}
</option>
))}
</select>
+17 -9
View File
@@ -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'
}
+53
View File
@@ -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
}