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:
103
client/src/pages/ChallengesPage.tsx
Normal file
103
client/src/pages/ChallengesPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user