From 8d304545a3e94646644e8619c58f8a05a3233c02 Mon Sep 17 00:00:00 2001 From: owain Date: Sun, 7 Jun 2026 23:49:01 +0100 Subject: [PATCH] Health page: VO2 max gauge, layout improvements, 3Y/5Y trends, biological sex profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/profile.py | 6 + backend/app/main.py | 9 ++ backend/app/models/user.py | 1 + frontend/src/pages/HealthPage.jsx | 228 ++++++++++++++++++++++------- frontend/src/pages/ProfilePage.jsx | 20 ++- 5 files changed, 209 insertions(+), 55 deletions(-) diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index b061606..7668676 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 2861843..b6b5174 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f2b0a61..bf6c9f0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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) diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index ae1a002..6e4ac3b 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -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 ( +
+ + {/* Background track */} + + {/* 5 coloured category segments */} + {VO2_CATEGORIES.map((c, i) => ( + + ))} + {/* Filled arc up to current value */} + {needleAngle != null && needleAngle > startDeg && ( + + )} + {/* 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 + })()} + {/* Value */} + + {value != null ? value.toFixed(1) : '--'} + + {/* Category label */} + + {cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')} + + +
+ ) +} + 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 (

📊

@@ -341,9 +451,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
{/* Sleep (wide) + Heart / HRV */} -
+
-
+

Sleep

{day.sleep_score != null && ( @@ -396,56 +506,58 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag ) : null}
-
-

Heart & HRV

-
-

Resting HR

-
- - {day.resting_hr ? Math.round(day.resting_hr) : '--'} - - bpm +
+

Heart & HRV

+
+
+

Resting HR

+
+ + {day.resting_hr ? Math.round(day.resting_hr) : '--'} + + bpm +
+ {avg30?.resting_hr && day.resting_hr && ( +

+ 30d avg {Math.round(avg30.resting_hr)} + {day.resting_hr < avg30.resting_hr + ? + : day.resting_hr > avg30.resting_hr + ? + : null} +

+ )}
- {avg30?.resting_hr && day.resting_hr && ( -

- 30d avg {Math.round(avg30.resting_hr)} bpm - {day.resting_hr < avg30.resting_hr - ? - : day.resting_hr > avg30.resting_hr - ? - : null} -

- )} -
-
-

HRV

-
- - {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'} - - ms - +
+

HRV

+
+ + {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'} + + ms +
+
-
- {day.avg_hr_day && (

Avg HR (day)

- {Math.round(day.avg_hr_day)} - {day.max_hr_day && / {Math.round(day.max_hr_day)} max bpm} + + {day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'} + + {day.max_hr_day && / {Math.round(day.max_hr_day)} max}
- )} - {day.weight_kg && (

Weight

- {day.weight_kg.toFixed(1)} - kg + + {day.weight_kg ? day.weight_kg.toFixed(1) : '--'} + + {day.weight_kg && kg} {day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat}
- )} +
@@ -519,14 +631,16 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag {stressLabel &&

{stressLabel}

}
-
+

VO2 Max

-
- - {(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'} - +
+
- {day.fitness_age &&

Fitness age {day.fitness_age}

} + {day.fitness_age &&

Fitness age {day.fitness_age}

}
@@ -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() {

VO2 Max

v.toFixed(1)} + connectNulls showDots selectedDate={selDateForCharts} onDayClick={handleDayClick} />
)} diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index a8f1c8d..9d24aa4 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -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() { setHrForm(f => ({ ...f, height_cm: e.target.value }))} /> + +
+ {['male', 'female'].map(s => ( + + ))} +
+
{(avgRestingHr || healthSummary?.latest?.weight_kg) && ( @@ -268,7 +284,7 @@ export default function ProfilePage() { { 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)