frontend: standardise metric colours + crisp activity icons platform-wide
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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user