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 ( +
📊
@@ -341,9 +451,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStagResting HR
-Resting HR
++ 30d avg {Math.round(avg30.resting_hr)} + {day.resting_hr < avg30.resting_hr + ? ↓ + : day.resting_hr > avg30.resting_hr + ? ↑ + : null} +
+ )}- 30d avg {Math.round(avg30.resting_hr)} bpm - {day.resting_hr < avg30.resting_hr - ? ↓ - : day.resting_hr > avg30.resting_hr - ? ↑ - : null} -
- )} -HRV
-HRV
+Avg HR (day)
Weight
{stressLabel}
}VO2 Max
-Fitness age {day.fitness_age}
} + {day.fitness_age &&Fitness age {day.fitness_age}
}