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

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
apiClient, AutoSyncStatus, errorMessage, GarminLoginStatus, parseUtc,
SyncStatus, UserSettings,
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
parseUtc, SyncStatus, UserSettings,
} from '../services/api';
import Screen from '../components/Screen';
import './DataSync.css';
@@ -25,6 +25,7 @@ function SyncPage() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [auto, setAuto] = useState<AutoSyncStatus | null>(null);
const [settings, setSettings] = useState<UserSettings | null>(null);
const [details, setDetails] = useState<DetailSyncStatus | null>(null);
const [hasToken, setHasToken] = useState<boolean | null>(null);
// Garmin login (only needed until a token is stored)
@@ -49,12 +50,14 @@ function SyncPage() {
const refresh = useCallback(async () => {
try {
const [s, a] = await Promise.all([
const [s, a, d] = await Promise.all([
apiClient.getGarminSyncStatus(),
apiClient.getAutoSyncStatus().catch(() => null),
apiClient.getDetailSyncStatus().catch(() => null),
]);
setSyncStatus(s);
if (a) setAuto(a);
if (d) setDetails(d);
return s;
} catch {
// A failed status poll should not blank the page.
@@ -196,6 +199,34 @@ function SyncPage() {
}
};
/** Pull the per-activity detail for anything stored without it. */
const syncDetails = async () => {
setError('');
setMessage('');
try {
setDetails(await apiClient.startDetailSync());
beginDetailPolling();
} catch (err: any) {
setError(errorMessage(err, '同步失败'));
}
};
const beginDetailPolling = () => {
const timer = window.setInterval(async () => {
try {
const d = await apiClient.getDetailSyncStatus();
setDetails(d);
if (!d.running) {
window.clearInterval(timer);
if (d.error) setError(d.error);
else if (d.done) setMessage(`已补齐 ${d.done} 条运动的详细数据`);
}
} catch {
window.clearInterval(timer);
}
}, 2000);
};
// The backfill runs in the background, so the page follows it by polling
// rather than by holding a request open for the whole thing.
const beginSyncPolling = () => {
@@ -331,6 +362,15 @@ function SyncPage() {
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
{historyLabel(settings?.historyDays ?? 365)}
</button>
<button
className="btn btn-plain"
onClick={syncDetails}
disabled={busy || !!details?.running}
>
{details?.running
? `正在补运动详情 ${details.done}/${details.total || '…'}`
: '补齐运动详情'}
</button>
</div>
)}