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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,13 +5,17 @@ const accentColors = {
|
|||||||
green: 'text-green-400',
|
green: 'text-green-400',
|
||||||
orange: 'text-orange-400',
|
orange: 'text-orange-400',
|
||||||
purple: 'text-purple-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 (
|
return (
|
||||||
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
|
<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-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>}
|
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { format } from 'date-fns'
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, sportColor, convertKm, distanceUnitLabel,
|
formatDate, sportColor, convertKm, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
import { useUnit } from '../hooks/useUnits'
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
import SportIcon from '../components/ui/SportIcon'
|
||||||
|
|
||||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||||
|
|
||||||
@@ -59,10 +60,16 @@ export default function ActivitiesPage() {
|
|||||||
{ytdStats && (
|
{ytdStats && (
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
<div className="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-sm">
|
||||||
{ytdStats.running_km > 0 && (
|
{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 && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -83,13 +90,14 @@ export default function ActivitiesPage() {
|
|||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
onClick={() => { setSport(s); setPage(1) }}
|
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
|
sport === s
|
||||||
? 'bg-blue-600 border-blue-600 text-white'
|
? 'bg-blue-600 border-blue-600 text-white'
|
||||||
: 'border-gray-700 text-gray-400 hover:text-white hover:border-gray-500'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -107,10 +115,10 @@ export default function ActivitiesPage() {
|
|||||||
>
|
>
|
||||||
{/* Sport indicator */}
|
{/* Sport indicator */}
|
||||||
<div
|
<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' }}
|
style={{ backgroundColor: sportColor(activity.sport_type) + '22' }}
|
||||||
>
|
>
|
||||||
{sportIcon(activity.sport_type)}
|
<SportIcon sport={activity.sport_type} size={20} color={sportColor(activity.sport_type)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + date */}
|
{/* Name + date */}
|
||||||
@@ -160,7 +168,7 @@ export default function ActivitiesPage() {
|
|||||||
|
|
||||||
{activities?.length === 0 && (
|
{activities?.length === 0 && (
|
||||||
<div className="text-center py-16 text-gray-600">
|
<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-lg">No activities yet</p>
|
||||||
<p className="text-sm mt-1">
|
<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
|
<Link to="/upload" className="text-blue-400 hover:underline">Import your Garmin or Strava data</Link> to get started
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ import RouteLeaderboard from '../components/activity/RouteLeaderboard'
|
|||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatElevation,
|
formatDuration, formatDistance, formatPace, formatElevation,
|
||||||
formatHeartRate, formatDateTime, formatCadence, sportIcon,
|
formatHeartRate, formatDateTime, formatCadence, sportColor,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
import { useUnit } from '../hooks/useUnits'
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
import SportIcon from '../components/ui/SportIcon'
|
||||||
|
|
||||||
import { projectToTrack } from '../utils/track'
|
import { projectToTrack } from '../utils/track'
|
||||||
|
|
||||||
const METRICS = [
|
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: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
|
||||||
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
|
||||||
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
|
||||||
@@ -156,7 +157,7 @@ export default function ActivityDetailPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<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 ? (
|
{editingName ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import SleepHypnogram from '../components/health/SleepHypnogram'
|
|||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
formatDate, sportIcon, sportColor, formatSleep, convertKm, distanceUnitLabel,
|
formatDate, sportColor, formatSleep, convertKm, distanceUnitLabel,
|
||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
import { useUnit } from '../hooks/useUnits'
|
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'
|
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||||
|
|
||||||
const Grid = WidthProvider(GridLayout)
|
const Grid = WidthProvider(GridLayout)
|
||||||
@@ -30,12 +32,12 @@ const tooltipStyle = { background: '#111827', border: '1px solid #374151', borde
|
|||||||
const STAT_DEFS = {
|
const STAT_DEFS = {
|
||||||
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
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_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_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_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_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_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 : '--' },
|
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 ────────────────────────────────────────────────────────────
|
// ── Chart widgets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Sport icon centred inside the activity band that sits below the battery bars.
|
// 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
|
if (!viewBox) return null
|
||||||
const { x, y, width = 0, height = 0 } = viewBox
|
const { x, y, width = 0, height = 0 } = viewBox
|
||||||
|
const size = 13
|
||||||
return (
|
return (
|
||||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="central"
|
<SportIcon sport={sport} size={size} color="#fff"
|
||||||
fontSize={13} style={{ pointerEvents: 'none' }}>
|
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
|
||||||
{icon}
|
style={{ pointerEvents: 'none' }} />
|
||||||
</text>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +187,7 @@ function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd, activities }) {
|
|||||||
return (
|
return (
|
||||||
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={BB_ACTIVITY_BAND_BOTTOM}
|
<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}
|
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
||||||
label={<BBActivityRefLabel icon={sportIcon(a.sport_type)} />} />
|
label={<BBActivityRefLabel sport={a.sport_type} />} />
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -228,24 +230,25 @@ function Sparkline({ data, dataKey, color, gradId, fmt }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Vo2MaxTrend({ health, recentHealth }) {
|
function Vo2MaxTrend({ health, recentHealth, profile }) {
|
||||||
const series = useMemo(
|
const series = useMemo(
|
||||||
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
|
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
|
||||||
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
.sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||||
.map(d => ({ date: d.date, v: d.vo2max })),
|
.map(d => ({ date: d.date, v: d.vo2max })),
|
||||||
[recentHealth],
|
[recentHealth],
|
||||||
)
|
)
|
||||||
|
const color = vo2Color(health.vo2max, profile?.birth_year, profile?.biological_sex)
|
||||||
return (
|
return (
|
||||||
<Card title="VO₂ Max" viewHref="/health">
|
<Card title="VO₂ Max" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-2">
|
<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>
|
<span className="text-xs text-gray-500">ml/kg/min</span>
|
||||||
</div>
|
</div>
|
||||||
{health.fitness_age != null && <p className="text-xs text-gray-500 mt-0.5">Fitness age {health.fitness_age}</p>}
|
{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">
|
<div className="flex-1 min-h-0 mt-2">
|
||||||
{series.length >= 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>}
|
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,12 +268,12 @@ function WeightMini({ recentHealth }) {
|
|||||||
<Card title="Weight" viewHref="/health">
|
<Card title="Weight" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-2">
|
<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>
|
<span className="text-xs text-gray-500">kg</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 mt-2">
|
<div className="flex-1 min-h-0 mt-2">
|
||||||
{series.length >= 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>}
|
: <p className="text-xs text-gray-600">Not enough history</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,11 +281,12 @@ function WeightMini({ recentHealth }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Canonical sleep-stage palette — matches the Health page hypnogram/charts.
|
||||||
const SLEEP_STAGES = [
|
const SLEEP_STAGES = [
|
||||||
{ key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' },
|
{ key: 'sleep_deep_s', label: 'Deep', color: '#6366f1' },
|
||||||
{ key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' },
|
{ key: 'sleep_rem_s', label: 'REM', color: '#7c3aed' },
|
||||||
{ key: 'sleep_light_s', label: 'Light', color: '#60a5fa' },
|
{ key: 'sleep_light_s', label: 'Light', color: '#a78bfa' },
|
||||||
{ key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' },
|
{ key: 'sleep_awake_s', label: 'Awake', color: '#eab308' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function SleepDetail({ health, sleepStages }) {
|
function SleepDetail({ health, sleepStages }) {
|
||||||
@@ -292,7 +296,7 @@ function SleepDetail({ health, sleepStages }) {
|
|||||||
<Card title="Sleep" viewHref="/health">
|
<Card title="Sleep" viewHref="/health">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
<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 && (
|
{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>
|
<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="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 justify-between px-4 py-3 border-b border-gray-800">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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">
|
<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>
|
<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>
|
<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 => (
|
{activities.slice(0, 6).map(activity => (
|
||||||
<Link key={activity.id} to={`/activities/${activity.id}`}
|
<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">
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||||
{activity.named_route_name && (
|
{activity.named_route_name && (
|
||||||
@@ -632,13 +636,17 @@ export default function DashboardPage() {
|
|||||||
const renderWidget = (id) => {
|
const renderWidget = (id) => {
|
||||||
if (STAT_DEFS[id]) {
|
if (STAT_DEFS[id]) {
|
||||||
const d = 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} />
|
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
|
||||||
}
|
}
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'weekly': return <WeeklyChart activities={allActivities} />
|
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 '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 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
|
||||||
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
||||||
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
|
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
|
||||||
import api from '../utils/api'
|
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 { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||||
import HrvBadge from '../components/ui/HrvBadge'
|
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'
|
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
@@ -24,42 +26,6 @@ const RANGES = [
|
|||||||
|
|
||||||
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
|
// ── 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 }) {
|
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
||||||
const MIN = 30, MAX = 70
|
const MIN = 30, MAX = 70
|
||||||
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
|
// 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
|
// ACSM category boundaries for this user's age/sex
|
||||||
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
const thresholds = vo2Thresholds(age, biologicalSex)
|
||||||
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
|
||||||
const thresholds = row[1]
|
|
||||||
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
||||||
|
|
||||||
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
|
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 }}>
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
@@ -175,7 +139,7 @@ function IntradayHrChart({ values }) {
|
|||||||
<Tooltip contentStyle={tooltipStyle}
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
|
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} />
|
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -184,14 +148,14 @@ function IntradayHrChart({ values }) {
|
|||||||
|
|
||||||
// ── Body Battery ─────────────────────────────────────────────────────────────
|
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ActivityRefLabel({ viewBox, icon }) {
|
function ActivityRefLabel({ viewBox, sport }) {
|
||||||
if (!viewBox) return null
|
if (!viewBox) return null
|
||||||
const { x, y, width = 0, height = 0 } = viewBox
|
const { x, y, width = 0, height = 0 } = viewBox
|
||||||
|
const size = 14
|
||||||
return (
|
return (
|
||||||
<text x={x + width / 2} y={y + height / 2} textAnchor="middle" dominantBaseline="central"
|
<SportIcon sport={sport} size={size} color="#fff"
|
||||||
fontSize={13} style={{ pointerEvents: 'none' }}>
|
x={x + width / 2 - size / 2} y={y + height / 2 - size / 2}
|
||||||
{icon}
|
style={{ pointerEvents: 'none' }} />
|
||||||
</text>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +241,7 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities })
|
|||||||
return (
|
return (
|
||||||
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
<ReferenceArea key={`area-${a.id}`} x1={x1} x2={x2} y1={0} y2={ACTIVITY_BAND_BOTTOM}
|
||||||
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
fill={color} fillOpacity={0.9} stroke={color} strokeOpacity={1}
|
||||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />} />
|
label={<ActivityRefLabel sport={a.sport_type} />} />
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
@@ -348,10 +312,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
: day.avg_stress < 25 ? 'Restful'
|
: day.avg_stress < 25 ? 'Restful'
|
||||||
: day.avg_stress < 50 ? 'Low'
|
: day.avg_stress < 50 ? 'Low'
|
||||||
: day.avg_stress < 75 ? 'Medium' : 'High'
|
: day.avg_stress < 75 ? 'Medium' : 'High'
|
||||||
const stressColor = !day.avg_stress ? 'text-white'
|
const stressColor = day.avg_stress ? 'text-orange-400' : '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'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -373,13 +334,13 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||||
{day.sleep_score != null && (
|
{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)}
|
Score {Math.round(day.sleep_score)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<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)}
|
{formatSleep(day.sleep_duration_s)}
|
||||||
</span>
|
</span>
|
||||||
{day.sleep_start && day.sleep_end && (
|
{day.sleep_start && day.sleep_end && (
|
||||||
@@ -428,7 +389,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<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) : '--'}
|
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">bpm</span>
|
<span className="text-sm text-gray-500">bpm</span>
|
||||||
@@ -457,7 +418,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<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) : '--'}
|
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
|
||||||
</span>
|
</span>
|
||||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</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>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
||||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
<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) : '--'}
|
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
|
||||||
</span>
|
</span>
|
||||||
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
|
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
|
||||||
@@ -799,8 +760,8 @@ function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
|
|||||||
}}>
|
}}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<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}
|
<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 }} />
|
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
|
<Area type="monotone" dataKey="w" stroke="#3b82f6" strokeWidth={2}
|
||||||
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
|
fill="url(#grad-weight)" dot={{ fill: '#3b82f6', r: 3, strokeWidth: 0 }}
|
||||||
connectNulls isAnimationActive={false} />
|
connectNulls isAnimationActive={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -916,6 +877,12 @@ export default function HealthPage() {
|
|||||||
return found ? found.vo2max : null
|
return found ? found.vo2max : null
|
||||||
}, [allDaysSorted])
|
}, [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.
|
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
|
||||||
const snapshotWeight = useMemo(() => {
|
const snapshotWeight = useMemo(() => {
|
||||||
if (!selectedDay) return null
|
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">
|
<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>
|
<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)}
|
formatter={v => Math.round(v)}
|
||||||
domain={[0, 200]}
|
domain={[0, 200]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
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">
|
<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>
|
<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)}
|
formatter={v => Math.round(v)}
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
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">
|
<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>
|
<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)}
|
formatter={v => Math.round(v)}
|
||||||
domain={[0, 200]}
|
domain={[0, 200]}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
@@ -1131,7 +1098,7 @@ export default function HealthPage() {
|
|||||||
{metrics.some(d => d.vo2max) && (
|
{metrics.some(d => d.vo2max) && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<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>
|
<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)}
|
formatter={v => v.toFixed(1)}
|
||||||
domain={vo2Domain}
|
domain={vo2Domain}
|
||||||
connectNulls showDots
|
connectNulls showDots
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export default function ProfilePage() {
|
|||||||
{healthSummary?.latest?.weight_kg && (
|
{healthSummary?.latest?.weight_kg && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
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'
|
import { useUnit } from '../hooks/useUnits'
|
||||||
|
|
||||||
// Decode Google encoded polyline to [[lat,lng], ...]
|
// Decode Google encoded polyline to [[lat,lng], ...]
|
||||||
@@ -300,7 +300,7 @@ export default function RoutesPage() {
|
|||||||
<option value="">Select an activity…</option>
|
<option value="">Select an activity…</option>
|
||||||
{recentActivities?.map(a => (
|
{recentActivities?.map(a => (
|
||||||
<option key={a.id} value={a.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -105,18 +105,26 @@ export function hrZoneColor(zone) {
|
|||||||
return colors[zone] || '#9ca3af'
|
return colors[zone] || '#9ca3af'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sportIcon(sportType) {
|
// Standard metric colours, used everywhere a metric is charted or shown as a stat
|
||||||
const icons = {
|
// so the whole platform stays consistent. (Sleep keeps its per-stage graph colours;
|
||||||
running: '🏃', cycling: '🚴', hiking: '🥾',
|
// SLEEP here is the light-sleep violet used for sleep *stat* text. VO2 max is the
|
||||||
walking: '🚶', other: '⚡',
|
// only metric coloured dynamically — by its gauge category — so it lives elsewhere.)
|
||||||
}
|
export const METRIC_COLOR = {
|
||||||
return icons[sportType?.toLowerCase()] || '⚡'
|
HEART_RATE: '#ef4444', // red
|
||||||
|
SLEEP: '#a78bfa', // light-sleep violet (stat text)
|
||||||
|
STRESS: '#f97316', // orange
|
||||||
|
STEPS: '#fbbf24', // yellow
|
||||||
|
WEIGHT: '#3b82f6', // blue
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sportColor(sportType) {
|
export function sportColor(sportType) {
|
||||||
const colors = {
|
const colors = {
|
||||||
running: '#3b82f6', cycling: '#f97316',
|
running: '#22c55e', // green
|
||||||
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user