feat(sync): 运动详情改为同步入库,详情页只读本地
按需回源是错的:点一次运动要等七个 Garmin 接口,网络好的时候慢, 网络差的时候直接超时(实测公网下 Network Error)。 - sync_data 顺带补齐缺详情的运动 - POST /api/garmin/sync-details 后台补齐存量,GET 查进度 - 详情页只读本地库;没有就提示去同步,不再回源 - 同步页新增「补齐运动详情」按钮,带进度 身体年龄:加入公开的阻尼系数 - 34 岁 VO₂max 46 原本算出 21 岁。不是算错,是方法本身会饱和: 人与人之间的 VO₂max 标准差约 7,而年龄每年只带来约 0.35 的衰减, 于是稍微能练的人都会撞到参考表最年轻一档。 - 按 50% 向实际年龄收拢,收敛范围 ±20 → ±12 岁,同一算例现在给 27 岁。 - 去掉「高于最年轻一档按 20 岁计」的硬地板,那是一道正好落在用户身上的悬崖。 - 界面同时显示未收拢的原始值,阻尼系数写进评分依据。 路由:为每个路径补无斜杠别名 - F7 写地址栏时去掉尾斜杠,于是 /daily/ 在地址栏是 /daily, 而那个地址匹配不到任何路由,刷新或分享就落到「找不到页面」。 布局:让页面结构上无法被撑宽 - 网格改用 minmax(min(210px,100%),1fr):裸的 minmax(210px,1fr) 允许 两列加起来超过窄屏宽度,第二张卡就被切掉在屏幕外。 - .ring-row 用 minmax(0,1fr),1fr 会以 min-content 兜底,一句长说明就能 把整行顶宽。 - .page-inner 加 overflow-x: clip。 - html/body 用 100dvh:手机浏览器把自己的地址栏盖在布局视口上, 100% 高的应用会把底部 Tab 栏顶到它们下面——对用户来说就是没有 Tab 栏。 测试:新增 122 项(设置 44、身体年龄 44、运动详情 42),全量 446 项通过。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,11 @@ step it took is returned alongside the number for the UI to display.
|
||||
Method
|
||||
------
|
||||
1. Base age from VO2max: the age at which the user's VO2max equals the median
|
||||
for their sex, interpolated over the reference table below. VO2max is the
|
||||
single strongest fitness predictor and is what Garmin's own model leans on.
|
||||
for their sex, interpolated over the reference table below, then damped
|
||||
towards their real age — see VO2_DAMPING for why that damping has to exist.
|
||||
2. Resting-heart-rate adjustment, relative to a 60 bpm reference.
|
||||
3. BMI adjustment, relative to the healthy 18.5–24.9 band.
|
||||
4. Clamped to within 20 years of chronological age — beyond that the
|
||||
extrapolation says more about the table's edges than about the person.
|
||||
4. Clamped to within MAX_DEVIATION years of chronological age.
|
||||
|
||||
Reference values are 50th-percentile VO2max (ml/kg/min) by age and sex, from
|
||||
the widely published ACSM / Cooper Institute cardiorespiratory fitness norms.
|
||||
@@ -37,7 +36,15 @@ BMI_LOW, BMI_HIGH = 18.5, 24.9
|
||||
BMI_YEARS_PER_UNIT = 0.5
|
||||
BMI_CAP = 5.0
|
||||
|
||||
MAX_DEVIATION = 20.0 # years either side of chronological age
|
||||
# Individual VO2max varies far more between people (SD ~7 ml/kg/min) than it
|
||||
# declines with age (~0.35 ml/kg/min per year), so a raw "what age is this
|
||||
# VO2max the median for" answer swings enormously: a VO2max of 46 at 34 reads
|
||||
# as 20, because it genuinely is the median for a 20-year-old. Published
|
||||
# fitness-age calculators all compress that swing; this one does too, by an
|
||||
# explicit factor that is shown to the user rather than buried.
|
||||
VO2_DAMPING = 0.5
|
||||
|
||||
MAX_DEVIATION = 12.0 # years either side of chronological age
|
||||
AGE_FLOOR, AGE_CEILING = 20.0, 85.0
|
||||
|
||||
# Rendered verbatim in 设置 → 评分依据. Kept here, next to the constants it
|
||||
@@ -52,8 +59,12 @@ BASIS = {
|
||||
{
|
||||
"name": "基准:VO₂max 对应年龄",
|
||||
"detail": "找出你的 VO₂max 相当于同性别人群哪个年龄的中位水平,"
|
||||
"在参考表上线性插值;高于最年轻一档时按 20 岁计,"
|
||||
"参考表再往上说明不了更多。",
|
||||
f"在参考表上线性插值,再按 {VO2_DAMPING:.0%} 的阻尼"
|
||||
"向实际年龄收拢。"
|
||||
"不收拢的话,个体差异会淹没年龄效应——人与人之间的 "
|
||||
"VO₂max 标准差约 7 ml/kg/min,而年龄每年只带来约 0.35 "
|
||||
"的衰减,于是稍微能练的人都会算出 20 岁。"
|
||||
"界面上同时显示未收拢的原始值。",
|
||||
"source": "ACSM / Cooper Institute 心肺适能人群常模(50 百分位)",
|
||||
},
|
||||
{
|
||||
@@ -84,16 +95,17 @@ BASIS = {
|
||||
def _interpolate_age(vo2, table):
|
||||
"""The age whose median VO2max equals `vo2`.
|
||||
|
||||
Above the youngest reference row the answer is simply "fitter than the
|
||||
median 25-year-old", and the table cannot say more: extrapolating its slope
|
||||
there gives absurdities (VO2max 48 reads as an eleven-year-old), so the
|
||||
result floors instead.
|
||||
Both ends continue along the nearest segment's slope. A hard floor here
|
||||
would put everyone above the youngest row at exactly the same age, which
|
||||
is a cliff precisely where the app's users sit; the damping applied to the
|
||||
result afterwards is what keeps the extrapolation from running away.
|
||||
"""
|
||||
first_age, first_vo2 = table[0]
|
||||
last_age, last_vo2 = table[-1]
|
||||
|
||||
if vo2 >= first_vo2:
|
||||
return AGE_FLOOR
|
||||
slope = (table[1][0] - first_age) / (table[1][1] - first_vo2)
|
||||
return first_age + (vo2 - first_vo2) * slope
|
||||
if vo2 <= last_vo2:
|
||||
slope = (last_age - table[-2][0]) / (last_vo2 - table[-2][1])
|
||||
return last_age + (vo2 - last_vo2) * slope
|
||||
@@ -122,11 +134,19 @@ def estimate(*, age, sex, vo2max, resting_hr=None, bmi=None):
|
||||
return {"value": None, "missing": missing, "basis": BASIS}
|
||||
|
||||
table = VO2_MEDIAN[sex]
|
||||
base = _interpolate_age(float(vo2max), table)
|
||||
chronological = float(age)
|
||||
raw = _interpolate_age(float(vo2max), table)
|
||||
|
||||
# Pull the raw figure back towards the user's real age by the damping
|
||||
# factor. Reported alongside the raw value so the compression is visible.
|
||||
base = chronological + (raw - chronological) * VO2_DAMPING
|
||||
|
||||
steps = [{
|
||||
"label": "VO₂max 基准",
|
||||
"input": f"{float(vo2max):.0f} ml/kg/min",
|
||||
"years": round(base, 1),
|
||||
"raw": round(raw, 1),
|
||||
"damping": VO2_DAMPING,
|
||||
"kind": "base",
|
||||
}]
|
||||
|
||||
@@ -160,7 +180,6 @@ def estimate(*, age, sex, vo2max, resting_hr=None, bmi=None):
|
||||
"kind": "adjust",
|
||||
})
|
||||
|
||||
chronological = float(age)
|
||||
clamped = max(chronological - MAX_DEVIATION,
|
||||
min(chronological + MAX_DEVIATION, total))
|
||||
clamped = max(AGE_FLOOR, min(AGE_CEILING, clamped))
|
||||
|
||||
Reference in New Issue
Block a user