Health page: VO2 max gauge, layout improvements, 3Y/5Y trends, biological sex profile
- 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:
@@ -19,6 +19,7 @@ class ProfileUpdate(BaseModel):
|
||||
resting_heart_rate: Optional[int] = None
|
||||
birth_year: Optional[int] = None
|
||||
height_cm: Optional[float] = None
|
||||
biological_sex: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
@@ -29,6 +30,7 @@ class ProfileOut(BaseModel):
|
||||
resting_heart_rate: Optional[int]
|
||||
birth_year: Optional[int]
|
||||
height_cm: Optional[float]
|
||||
biological_sex: Optional[str]
|
||||
estimated_max_hr: Optional[int]
|
||||
is_admin: bool
|
||||
|
||||
@@ -72,6 +74,10 @@ async def update_profile(
|
||||
if not (50 <= body.height_cm <= 300):
|
||||
raise HTTPException(400, "Height must be 50–300 cm")
|
||||
current_user.height_cm = body.height_cm
|
||||
if body.biological_sex is not None:
|
||||
if body.biological_sex not in ('male', 'female', ''):
|
||||
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
|
||||
current_user.biological_sex = body.biological_sex or None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
@@ -64,6 +64,15 @@ async def init_db():
|
||||
except Exception as e:
|
||||
print(f"health_metrics column migration skipped: {e}")
|
||||
|
||||
# biological_sex column on users added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"users.biological_sex column migration skipped: {e}")
|
||||
|
||||
# route_segments auto_generated column added after initial creation
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
|
||||
@@ -27,6 +27,7 @@ class User(Base):
|
||||
resting_heart_rate = Column(Integer, nullable=True)
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
height_cm = Column(Float, nullable=True)
|
||||
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
|
||||
|
||||
# PocketID config (stored per-user so admin can set via UI)
|
||||
pocketid_issuer = Column(String(512), nullable=True)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user