Health page: VO2 max gauge, layout improvements, 3Y/5Y trends, biological sex profile
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

- Add biological_sex field to User model, profile API, and ProfilePage toggle (male/female) — used to select the correct ACSM VO2 max threshold table
- Replace simple VO2 max number in daily snapshot with a colour-coded SVG radial gauge (Very Poor=red, Poor=orange, Fair=green, Good=blue, Excellent=purple) driven by sex- and age-appropriate thresholds
- Shrink Sleep widget to half-width, expand Heart & HRV to half-width; reorganise Heart & HRV internals into a 2×2 grid to reduce vertical height
- Add connectNulls + showDots to VO2 Max trend chart so sparse readings connect with a continuous line
- Add 3Y and 5Y range options to the Trends selector; increase allDays limit to 2000 for full 5yr snapshot navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:49:01 +01:00
parent 70c7e5c0a8
commit 8d304545a3
5 changed files with 209 additions and 55 deletions
+175 -53
View File
@@ -9,14 +9,124 @@ import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format'
const RANGES = [
{ label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
{ label: '6M', days: 180 },
{ label: '1Y', days: 365 },
{ label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
{ label: '6M', days: 180 },
{ label: '1Y', days: 365 },
{ label: '3Y', days: 1095 },
{ label: '5Y', days: 1825 },
]
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
// ACSM sex-specific VO2 max thresholds
// [maxAge, [veryPoor_max, poor_max, fair_max, good_max]] (above good_max → Excellent)
const VO2_MALE = [
[29, [32, 36, 41, 45]],
[39, [30, 34, 38, 43]],
[49, [29, 33, 37, 42]],
[59, [25, 30, 34, 38]],
[69, [20, 24, 28, 32]],
[Infinity, [17, 21, 24, 28]],
]
const VO2_FEMALE = [
[29, [27, 33, 36, 41]],
[39, [26, 30, 35, 39]],
[49, [24, 28, 32, 36]],
[59, [21, 24, 28, 32]],
[69, [18, 21, 24, 28]],
[Infinity, [15, 18, 22, 25]],
]
const VO2_CATEGORIES = [
{ label: 'Very Poor', color: '#ef4444' },
{ label: 'Poor', color: '#f97316' },
{ label: 'Fair', color: '#22c55e' },
{ label: 'Good', color: '#3b82f6' },
{ label: 'Excellent', 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]
const idx = thresholds.findIndex(t => value <= t)
return idx === -1 ? VO2_CATEGORIES[4] : VO2_CATEGORIES[idx]
}
function arcPath(cx, cy, r, startDeg, endDeg) {
const toRad = d => (d - 90) * Math.PI / 180
const x1 = cx + r * Math.cos(toRad(startDeg))
const y1 = cy + r * Math.sin(toRad(startDeg))
const x2 = cx + r * Math.cos(toRad(endDeg))
const y2 = cy + r * Math.sin(toRad(endDeg))
const large = endDeg - startDeg > 180 ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`
}
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
const age = birthYear ? new Date().getFullYear() - birthYear : 40
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
// Gauge spans 180° — from 180° (9 o'clock) to 360° (3 o'clock) across the top
const cx = 70, cy = 72, r = 52, sw = 14
const startDeg = 180, totalDeg = 180
const segDeg = totalDeg / 5
// Compute value angle for needle
let needleAngle = null
if (value != null) {
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
const thresholds = row[1]
const rangeMin = thresholds[0] - 8 // below Very Poor threshold
const rangeMax = thresholds[3] + 12 // above Good threshold (Excellent zone)
const pct = Math.max(0, Math.min(1, (value - rangeMin) / (rangeMax - rangeMin)))
needleAngle = startDeg + pct * totalDeg
}
return (
<div className="flex flex-col items-center">
<svg width="140" height="80" viewBox="0 0 140 80">
{/* Background track */}
<path d={arcPath(cx, cy, r, startDeg, startDeg + totalDeg)}
stroke="#1f2937" strokeWidth={sw} fill="none" strokeLinecap="butt" />
{/* 5 coloured category segments */}
{VO2_CATEGORIES.map((c, i) => (
<path key={i}
d={arcPath(cx, cy, r, startDeg + i * segDeg, startDeg + (i + 1) * segDeg)}
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt"
strokeOpacity={0.25}
/>
))}
{/* Filled arc up to current value */}
{needleAngle != null && needleAngle > startDeg && (
<path d={arcPath(cx, cy, r, startDeg, needleAngle)}
stroke={cat?.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
)}
{/* Needle dot */}
{needleAngle != null && (() => {
const toRad = d => (d - 90) * Math.PI / 180
const nx = cx + r * Math.cos(toRad(needleAngle))
const ny = cy + r * Math.sin(toRad(needleAngle))
return <circle cx={nx} cy={ny} r={5} fill={cat?.color} />
})()}
{/* Value */}
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
fontSize="22" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
{value != null ? value.toFixed(1) : '--'}
</text>
{/* Category label */}
<text x={cx} y={cy + 14} textAnchor="middle" dominantBaseline="middle"
fontSize="9" fill="#9ca3af">
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
</text>
</svg>
</div>
)
}
const tooltipStyle = {
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
}
@@ -304,7 +414,7 @@ function NavArrow({ onClick, disabled, children }) {
)
}
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, onOlder, onNewer, hasOlder, hasNewer }) {
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
@@ -341,9 +451,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
</div>
{/* Sleep (wide) + Heart / HRV */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
{day.sleep_score != null && (
@@ -396,56 +506,58 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
) : null}
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
<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">
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart & HRV</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<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">
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
</div>
{avg30?.resting_hr && day.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)}
{day.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span>
: day.resting_hr > avg30.resting_hr
? <span className="text-red-400 ml-1"></span>
: null}
</p>
)}
</div>
{avg30?.resting_hr && day.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)} bpm
{day.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span>
: day.resting_hr > avg30.resting_hr
? <span className="text-red-400 ml-1"></span>
: null}
</p>
)}
</div>
<div>
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-3xl font-bold text-violet-400">
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
</span>
<span className="text-sm text-gray-500">ms</span>
<HrvBadge status={day.hrv_status} />
<div>
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-3xl font-bold text-violet-400">
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
</span>
<span className="text-sm text-gray-500">ms</span>
</div>
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
</div>
</div>
{day.avg_hr_day && (
<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">{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 bpm</span>}
<span className="text-xl font-semibold text-orange-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>}
</div>
</div>
)}
{day.weight_kg && (
<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">{day.weight_kg.toFixed(1)}</span>
<span className="text-xs text-gray-500">kg</span>
<span className="text-xl font-semibold text-emerald-400">
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
</span>
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>}
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
</div>
</div>
)}
</div>
</div>
</div>
@@ -519,14 +631,16 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
</div>
<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 flex flex-col">
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">
{(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
</span>
<div className="flex-1 flex items-center justify-center">
<Vo2MaxGauge
value={(day.vo2max ?? latestVo2max) ?? null}
birthYear={birthYear}
biologicalSex={biologicalSex}
/>
</div>
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
{day.fitness_age && <p className="text-xs text-gray-500 mt-1 text-center">Fitness age {day.fitness_age}</p>}
</div>
</div>
</div>
@@ -637,12 +751,17 @@ export default function HealthPage() {
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
// Full history for snapshot navigation.
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
const { data: allDays } = useQuery({
queryKey: ['health-metrics', 'all'],
queryFn: () =>
api.get('/health-metrics/', { params: { limit: 365 } })
api.get('/health-metrics/', { params: { limit: 2000 } })
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
})
@@ -721,6 +840,8 @@ export default function HealthPage() {
sleepStages={intradayData?.sleep_stages}
activities={dayActivities}
latestVo2max={latestVo2max}
birthYear={profile?.birth_year}
biologicalSex={profile?.biological_sex}
onOlder={goOlder}
onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
@@ -857,6 +978,7 @@ export default function HealthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
formatter={v => v.toFixed(1)}
connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
)}
+18 -2
View File
@@ -74,7 +74,7 @@ export default function ProfilePage() {
}, [recentMetrics])
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' })
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '' })
const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false)
@@ -83,6 +83,7 @@ export default function ProfilePage() {
max_heart_rate: profile.max_heart_rate || '',
birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '',
biological_sex: profile.biological_sex || '',
})
}, [profile])
@@ -246,6 +247,21 @@ export default function ProfilePage() {
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
</Field>
<Field label="Biological sex" hint="Used for VO2 max fitness category thresholds">
<div className="flex gap-2">
{['male', 'female'].map(s => (
<button key={s} type="button"
onClick={() => setHrForm(f => ({ ...f, biological_sex: f.biological_sex === s ? '' : s }))}
className={`flex-1 py-2 rounded-lg text-sm border transition-colors capitalize ${
hrForm.biological_sex === s
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{s}
</button>
))}
</div>
</Field>
</div>
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
@@ -268,7 +284,7 @@ export default function ProfilePage() {
<SaveButton
onClick={() => {
const data = Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, k === 'biological_sex' ? v : parseFloat(v)])
)
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
updateProfile.mutate(data)