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
|
resting_heart_rate: Optional[int] = None
|
||||||
birth_year: Optional[int] = None
|
birth_year: Optional[int] = None
|
||||||
height_cm: Optional[float] = None
|
height_cm: Optional[float] = None
|
||||||
|
biological_sex: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileOut(BaseModel):
|
class ProfileOut(BaseModel):
|
||||||
@@ -29,6 +30,7 @@ class ProfileOut(BaseModel):
|
|||||||
resting_heart_rate: Optional[int]
|
resting_heart_rate: Optional[int]
|
||||||
birth_year: Optional[int]
|
birth_year: Optional[int]
|
||||||
height_cm: Optional[float]
|
height_cm: Optional[float]
|
||||||
|
biological_sex: Optional[str]
|
||||||
estimated_max_hr: Optional[int]
|
estimated_max_hr: Optional[int]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ async def update_profile(
|
|||||||
if not (50 <= body.height_cm <= 300):
|
if not (50 <= body.height_cm <= 300):
|
||||||
raise HTTPException(400, "Height must be 50–300 cm")
|
raise HTTPException(400, "Height must be 50–300 cm")
|
||||||
current_user.height_cm = body.height_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.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"health_metrics column migration skipped: {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
|
# route_segments auto_generated column added after initial creation
|
||||||
try:
|
try:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class User(Base):
|
|||||||
resting_heart_rate = Column(Integer, nullable=True)
|
resting_heart_rate = Column(Integer, nullable=True)
|
||||||
birth_year = Column(Integer, nullable=True)
|
birth_year = Column(Integer, nullable=True)
|
||||||
height_cm = Column(Float, 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 config (stored per-user so admin can set via UI)
|
||||||
pocketid_issuer = Column(String(512), nullable=True)
|
pocketid_issuer = Column(String(512), nullable=True)
|
||||||
|
|||||||
@@ -15,8 +15,118 @@ const RANGES = [
|
|||||||
{ label: '3M', days: 90 },
|
{ label: '3M', days: 90 },
|
||||||
{ label: '6M', days: 180 },
|
{ label: '6M', days: 180 },
|
||||||
{ label: '1Y', days: 365 },
|
{ 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 = {
|
const tooltipStyle = {
|
||||||
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
|
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 (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<p className="text-3xl mb-2">📊</p>
|
||||||
@@ -341,9 +451,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sleep (wide) + Heart / HRV */}
|
{/* 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">
|
<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 && (
|
||||||
@@ -396,8 +506,9 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
|
<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>
|
<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">
|
||||||
@@ -408,7 +519,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
</div>
|
</div>
|
||||||
{avg30?.resting_hr && day.resting_hr && (
|
{avg30?.resting_hr && day.resting_hr && (
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
30d avg {Math.round(avg30.resting_hr)} bpm
|
30d avg {Math.round(avg30.resting_hr)}
|
||||||
{day.resting_hr < avg30.resting_hr
|
{day.resting_hr < avg30.resting_hr
|
||||||
? <span className="text-green-400 ml-1">↓</span>
|
? <span className="text-green-400 ml-1">↓</span>
|
||||||
: day.resting_hr > avg30.resting_hr
|
: day.resting_hr > avg30.resting_hr
|
||||||
@@ -424,28 +535,29 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
|||||||
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">ms</span>
|
<span className="text-sm text-gray-500">ms</span>
|
||||||
<HrvBadge status={day.hrv_status} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
|
||||||
</div>
|
</div>
|
||||||
{day.avg_hr_day && (
|
|
||||||
<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">{Math.round(day.avg_hr_day)}</span>
|
<span className="text-xl font-semibold text-orange-400">
|
||||||
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max bpm</span>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{day.weight_kg && (
|
|
||||||
<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">{day.weight_kg.toFixed(1)}</span>
|
<span className="text-xl font-semibold text-emerald-400">
|
||||||
<span className="text-xs text-gray-500">kg</span>
|
{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>}
|
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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>}
|
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
|
||||||
</div>
|
</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>
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<span className="text-2xl font-bold text-blue-400">
|
<Vo2MaxGauge
|
||||||
{(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
|
value={(day.vo2max ?? latestVo2max) ?? null}
|
||||||
</span>
|
birthYear={birthYear}
|
||||||
|
biologicalSex={biologicalSex}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,12 +751,17 @@ export default function HealthPage() {
|
|||||||
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
|
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.
|
// Full history for snapshot navigation.
|
||||||
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
|
// Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
|
||||||
const { data: allDays } = useQuery({
|
const { data: allDays } = useQuery({
|
||||||
queryKey: ['health-metrics', 'all'],
|
queryKey: ['health-metrics', 'all'],
|
||||||
queryFn: () =>
|
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) }))),
|
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -721,6 +840,8 @@ export default function HealthPage() {
|
|||||||
sleepStages={intradayData?.sleep_stages}
|
sleepStages={intradayData?.sleep_stages}
|
||||||
activities={dayActivities}
|
activities={dayActivities}
|
||||||
latestVo2max={latestVo2max}
|
latestVo2max={latestVo2max}
|
||||||
|
birthYear={profile?.birth_year}
|
||||||
|
biologicalSex={profile?.biological_sex}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
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>
|
<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="#3b82f6"
|
||||||
formatter={v => v.toFixed(1)}
|
formatter={v => v.toFixed(1)}
|
||||||
|
connectNulls showDots
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function ProfilePage() {
|
|||||||
}, [recentMetrics])
|
}, [recentMetrics])
|
||||||
|
|
||||||
// HR / measurements form
|
// 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 [hrSaved, setHrSaved] = useState(false)
|
||||||
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
||||||
const maxHrChangedRef = useRef(false)
|
const maxHrChangedRef = useRef(false)
|
||||||
@@ -83,6 +83,7 @@ export default function ProfilePage() {
|
|||||||
max_heart_rate: profile.max_heart_rate || '',
|
max_heart_rate: profile.max_heart_rate || '',
|
||||||
birth_year: profile.birth_year || '',
|
birth_year: profile.birth_year || '',
|
||||||
height_cm: profile.height_cm || '',
|
height_cm: profile.height_cm || '',
|
||||||
|
biological_sex: profile.biological_sex || '',
|
||||||
})
|
})
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
@@ -246,6 +247,21 @@ export default function ProfilePage() {
|
|||||||
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
|
<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 }))} />
|
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
|
||||||
</Field>
|
</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>
|
</div>
|
||||||
|
|
||||||
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
|
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
|
||||||
@@ -268,7 +284,7 @@ export default function ProfilePage() {
|
|||||||
<SaveButton
|
<SaveButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const data = Object.fromEntries(
|
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
|
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
|
||||||
updateProfile.mutate(data)
|
updateProfile.mutate(data)
|
||||||
|
|||||||
Reference in New Issue
Block a user