feat: 补齐 Garmin 未同步的数据,并各自配上界面

审计 57 个接口后,把账号里真有数据却从未入库的部分补上。全部走同步模块,
界面只读本地库。

新增数据
- 体重与身体成分(体脂率/肌肉量/体水分/骨量/内脏脂肪/代谢年龄)
- 血压(接口通,账号暂无记录)
- 跑步成绩预测(5 公里 / 10 公里 / 半马 / 全马)
- 爬坡分、饮水量、出汗量 → health_data 新增七列
- 全天曲线:心率 / 压力 / 身体电量 / 呼吸 / 血氧
- 挑战赛(徽章挑战与好友挑战,与一次性的徽章不同,有周期和进度)
- 已配对设备

新增界面
- /body/ 身体成分:体重大数字 + BMI 分级 + 体脂肌肉曲线 + 血压表格
- /race/ 成绩预测:四个距离的预测成绩与配速,以及预测随时间的变化
- /challenges/ 挑战赛:按类型筛选,有目标的显示进度条
- /devices/ 已配对设备
- 每日页新增「全天曲线」,这是存日内采样的主要目的
- 健康页新增「身体成分」分组与「更多」入口,运动页加挑战赛与成绩预测入口

同步开销
- 日内曲线每天五个请求,14 天以内的同步顺带拉,更长的历史交给后台
  「补齐详细数据」,否则一年的同步会多出约 1800 个请求
- 原来的「补齐运动详情」扩展为统一的补齐任务,分阶段上报进度

日内采样抽稀到每天 240 点:手机图表分辨不出更多,只会把行撑大。

全量 446 项测试通过。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-24 04:16:33 +08:00
parent 34940cc387
commit f1319a6171
19 changed files with 1520 additions and 29 deletions

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, Challenge, errorMessage } from '../services/api';
import Screen from '../components/Screen';
import Skeleton from '../components/Skeleton';
import './Challenges.css';
const KIND_LABEL: Record<string, string> = {
badge: '徽章挑战',
adhoc: '好友挑战',
available: '可参加',
inprogress: '进行中',
};
/** Percentage complete, if the payload carries a target and a total. */
function progressOf(c: Challenge): number | null {
const p = c.payload || {};
const target = p.badgeTargetValue ?? p.targetValue ?? p.challengeTargetValue;
const current = p.userRankValue ?? p.badgeProgressValue ?? p.currentValue;
if (!target || current == null) return null;
return Math.min(100, Math.round((Number(current) / Number(target)) * 100));
}
function ChallengesPage() {
const [rows, setRows] = useState<Challenge[]>([]);
const [kind, setKind] = useState<string>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
apiClient
.getChallenges()
.then(setRows)
.catch((err) => setError(errorMessage(err, '加载失败')))
.finally(() => setLoading(false));
}, []);
const kinds = useMemo(
() => ['all', ...Array.from(new Set(rows.map((r) => r.kind)))],
[rows]
);
const shown = kind === 'all' ? rows : rows.filter((r) => r.kind === kind);
if (loading) return <Screen title="挑战赛" backLink><Skeleton count={5} /></Screen>;
return (
<Screen title="挑战赛" backLink subtitle={`${rows.length}`}>
{error && <div className="screen-error">{error}</div>}
{rows.length === 0 ? (
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round"></Link>
</div>
) : (
<>
{kinds.length > 2 && (
<div className="metric-tabs">
{kinds.map((k) => (
<button
key={k}
className={`metric-tab ${k === kind ? 'active' : ''}`}
onClick={() => setKind(k)}
>
{k === 'all' ? '全部' : KIND_LABEL[k] ?? k}
</button>
))}
</div>
)}
<div className="chal-list">
{shown.map((c, i) => {
const pct = progressOf(c);
return (
<div className="chal" key={`${c.uuid}-${i}`}>
<div className="chal-head">
<span className="chal-name">{c.name || '未命名挑战'}</span>
<span className="chal-kind">{KIND_LABEL[c.kind] ?? c.kind}</span>
</div>
{(c.startDate || c.endDate) && (
<div className="chal-dates">
{c.startDate} {c.endDate ? `${c.endDate}` : ''}
</div>
)}
{pct != null && (
<>
<div className="chal-bar">
<div className="chal-fill" style={{ width: `${pct}%` }} />
</div>
<div className="chal-pct">{pct}%</div>
</>
)}
</div>
);
})}
</div>
</>
)}
</Screen>
);
}
export default ChallengesPage;