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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user