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:
ericwyuan
2026-08-24 04:03:49 +08:00
parent fa865ca8a6
commit 34940cc387
16 changed files with 1218 additions and 69 deletions

View File

@@ -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.524.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))

View File

@@ -24,7 +24,7 @@ import json
import os
import threading
from db import execute, query_one
from db import execute, query_one, query_all
from config import DB_TYPE
from services import health
@@ -586,28 +586,7 @@ def _build_detail(client, activity_id):
}
def get_activity_detail(user_id, activity_id, creds=None, refresh=False):
"""Cached detail for one activity, fetched from Garmin on first open."""
activity_id = str(activity_id)
if not refresh:
row = query_one(
"SELECT payload FROM activity_details "
"WHERE user_id = ? AND activity_id = ?",
[user_id, activity_id],
)
if row and row.get("payload"):
try:
cached = json.loads(row["payload"])
cached["cached"] = True
return cached
except ValueError:
# A truncated row is worth refetching, not worth crashing on.
pass
client = _connect(creds or {}, user_id=user_id)
detail = _build_detail(client, activity_id)
def _store_detail(user_id, activity_id, detail):
cols = ["activity_id", "user_id", "payload", "fetched_at"]
values = [activity_id, user_id, json.dumps(detail, default=str),
datetime.datetime.utcnow().isoformat(timespec="seconds")]
@@ -622,8 +601,93 @@ def get_activity_detail(user_id, activity_id, creds=None, refresh=False):
f"({placeholders}) ON CONFLICT(activity_id) DO UPDATE SET {updates}")
execute(sql, values)
detail["cached"] = False
return detail
def read_activity_detail(user_id, activity_id):
"""The stored detail for one activity, or None if it was never synced."""
row = query_one(
"SELECT payload FROM activity_details WHERE user_id = ? AND activity_id = ?",
[user_id, str(activity_id)],
)
if not row or not row.get("payload"):
return None
try:
return json.loads(row["payload"])
except ValueError:
# A truncated row is worth re-syncing, not worth crashing on.
return None
def sync_activity_details(client, user_id, limit=None, on_progress=None):
"""Fetch and store the full detail for activities that lack one.
Detail used to be fetched when the user opened an activity, which meant
seven Garmin calls on the critical path of a tap: slow at best, and a
timeout whenever the link was poor. It belongs in the sync, so the screen
only ever reads the local database.
"""
rows = query_all(
"SELECT a.id FROM activities a "
"LEFT JOIN activity_details d ON d.activity_id = a.id "
"WHERE a.user_id = ? AND d.activity_id IS NULL "
"ORDER BY a.start_time DESC",
[user_id],
)
if limit:
rows = rows[:limit]
stored = 0
for i, row in enumerate(rows):
try:
_store_detail(user_id, row["id"], _build_detail(client, row["id"]))
stored += 1
except Exception: # noqa: BLE001 - one bad activity must not stop the rest
continue
if on_progress:
on_progress(i + 1, len(rows))
return stored
_detail_progress = {}
def detail_sync_status(user_id):
"""Progress of the detail backfill for this account."""
return _detail_progress.get(user_id) or {"running": False, "done": 0, "total": 0}
def start_detail_sync(user_id, limit=None):
"""Backfill activity details in the background.
Each activity costs several Garmin calls, so 170 of them run for minutes —
far too long to hold a request open. The UI polls instead.
"""
state = _detail_progress.get(user_id)
if state and state.get("running"):
return state
_detail_progress[user_id] = {"running": True, "done": 0, "total": 0, "error": None}
def run():
try:
client = _connect({}, user_id=user_id)
def progress(done, total):
_detail_progress[user_id] = {
"running": True, "done": done, "total": total, "error": None,
}
stored = sync_activity_details(client, user_id, limit, on_progress=progress)
_detail_progress[user_id] = {
"running": False, "done": stored,
"total": _detail_progress[user_id].get("total", stored), "error": None,
}
except Exception as e: # noqa: BLE001 - reported through the status endpoint
_detail_progress[user_id] = {
"running": False, "done": 0, "total": 0, "error": describe(e),
}
threading.Thread(target=run, daemon=True, name=f"detail-sync-{user_id}").start()
return _detail_progress[user_id]
# Above this many days a sync is long enough that the caller must not block
@@ -711,6 +775,14 @@ def sync_data(user_id, creds, days=None, client=None):
except Exception as e:
day_errors.append(f"activities: {describe(e)}")
# Full detail for any activity that does not have it stored yet, so that
# opening one later is a local read.
details_synced = 0
try:
details_synced = sync_activity_details(client, user_id)
except Exception as e:
day_errors.append(f"activity_details: {describe(e)}")
# Badges and personal records are account-wide rather than per-day, so
# they are fetched once per sync rather than inside the day loop.
badges_synced = 0
@@ -738,7 +810,8 @@ def sync_data(user_id, creds, days=None, client=None):
last_error="; ".join(day_errors[:3]) if day_errors else None,
)
message = (
f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录"
f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录"
f"(含 {details_synced} 条详情)、"
f"{badges_synced} 个奖励、{records_synced_pr} 项个人纪录"
)
if day_errors: