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:
@@ -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