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

@@ -129,6 +129,30 @@ export const METRICS: Record<string, MetricDef> = {
id: 'enduranceScore', label: '耐力分', pick: (d) => d.enduranceScore,
about: 'Garmin 由长期训练负荷与 VO₂max 推算的耐力水平,变化很慢。',
},
hillScore: {
id: 'hillScore', label: '爬坡分', pick: (d) => d.hillScore,
about: 'Garmin 根据爬坡时的输出功率与耐力评估的爬坡能力,只在有爬升的活动后更新。',
},
hydration: {
id: 'hydration', label: '饮水量', unit: 'ml', cumulative: true,
pick: (d) => d.hydrationMl,
about: '当天记录的饮水量,需要在 Garmin Connect 或手表上手动记录。',
},
sweatLoss: {
id: 'sweatLoss', label: '出汗量', unit: 'ml', cumulative: true,
pick: (d) => d.sweatLossMl,
about: '运动中的预估出汗量,由时长、强度与温度推算。',
},
weight: {
id: 'weight', label: '体重', unit: 'kg', decimals: 1,
pick: (d) => d.weightKg, route: '/body/',
about: '体脂秤或手动记录的体重。',
},
bodyFat: {
id: 'bodyFat', label: '体脂率', unit: '%', decimals: 1,
pick: (d) => d.bodyFatPct, route: '/body/',
about: '体脂秤用生物电阻抗估算,绝对值误差较大,看趋势更有意义。',
},
vo2max: {
id: 'vo2max', label: 'VO₂max', unit: 'ml/kg/min', pick: (d) => d.vo2max,
about: '最大摄氧量,心肺适能的核心指标。只在户外跑步或骑行后才会更新。',

View File

@@ -0,0 +1,190 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import {
apiClient, BloodPressureReading, BodyCompositionDay, errorMessage,
} from '../services/api';
import Screen from '../components/Screen';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import { daysAgo, today as todayIso } from '../lib/day';
import './MetricDetail.css';
const RANGES = [90, 180, 365, 730];
/** WHO adult BMI classes. Shown as words, never as a colour alone. */
function bmiClass(bmi: number | null) {
if (bmi == null) return null;
if (bmi < 18.5) return { label: '偏瘦', tone: 'warning' };
if (bmi < 25) return { label: '正常', tone: 'good' };
if (bmi < 30) return { label: '超重', tone: 'warning' };
return { label: '肥胖', tone: 'serious' };
}
function BodyPage() {
const [rows, setRows] = useState<BodyCompositionDay[]>([]);
const [pressure, setPressure] = useState<BloodPressureReading[]>([]);
const [range, setRange] = useState(365);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
apiClient
.getBodyComposition(daysAgo(range - 1), todayIso())
.then((r) => { if (!cancelled) setRows(r); })
.catch((err) => { if (!cancelled) setError(errorMessage(err, '加载失败')); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [range]);
useEffect(() => {
apiClient.getBloodPressure().then(setPressure).catch(() => setPressure([]));
}, []);
const latest = useMemo(
() => [...rows].reverse().find((r) => r.weightKg != null) ?? null,
[rows]
);
const chartRows = rows.map((r) => ({
date: r.date.slice(5),
weightKg: r.weightKg,
bodyFatPct: r.bodyFatPct,
muscleMassKg: r.muscleMassKg,
}));
const verdict = bmiClass(latest?.bmi ?? null);
if (loading && rows.length === 0) {
return <Screen title="身体成分" backLink><Skeleton count={4} /></Screen>;
}
return (
<Screen title="身体成分" backLink>
{error && <div className="screen-error">{error}</div>}
{rows.length === 0 && !error ? (
<div className="screen-empty">
<p> Garmin Connect Connect </p>
<Link href="/sync/" className="button button-fill button-round"></Link>
</div>
) : (
<>
<section className="md-hero">
<div className="md-value">
{latest?.weightKg != null ? latest.weightKg.toFixed(1) : '—'}
<span className="md-unit">kg</span>
</div>
{verdict && (
<div className="md-verdict">
<span className={`md-tone tone-${verdict.tone}`}>{verdict.label}</span>
<span className="md-target">BMI {latest?.bmi?.toFixed(1)}</span>
</div>
)}
<div className="md-when">
{latest ? `最近记录 ${latest.date}` : '暂无数据'}
</div>
</section>
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
{RANGES.map((d) => (
<button key={d} className={d === range ? 'on' : ''}
onClick={() => setRange(d)}>
{d >= 365 ? `${d / 365}` : `${d}`}
</button>
))}
</div>
</div>
{latest && (
<div className="md-stats">
{([
['体脂率', latest.bodyFatPct, '%'],
['肌肉量', latest.muscleMassKg, 'kg'],
['体水分', latest.bodyWaterPct, '%'],
['骨量', latest.boneMassKg, 'kg'],
['内脏脂肪', latest.visceralFat, ''],
['代谢年龄', latest.metabolicAge, '岁'],
] as Array<[string, number | null, string]>)
.filter(([, v]) => v != null)
.map(([label, value, unit]) => (
<div className="md-stat" key={label}>
<span className="md-stat-label">{label}</span>
<span className="md-stat-value">
{value!.toFixed(1)}{unit}
</span>
</div>
))}
</div>
)}
<section className="sec">
<Chart
title="体重"
unit="kg"
data={chartRows}
type="line"
height={210}
series={[{ key: 'weightKg', label: '体重', slot: 1, unit: 'kg', decimals: 1 }]}
/>
</section>
{chartRows.some((r) => r.bodyFatPct != null) && (
<section className="sec">
<Chart
title="体脂率与肌肉量"
data={chartRows}
type="line"
height={200}
series={[
{ key: 'bodyFatPct', label: '体脂率', slot: 2, unit: '%', decimals: 1 },
{ key: 'muscleMassKg', label: '肌肉量', slot: 3, unit: 'kg', decimals: 1 },
]}
/>
</section>
)}
</>
)}
<section className="sec">
<h3 className="sec-title"></h3>
{pressure.length === 0 ? (
<p className="screen-note">
Garmin Connect
</p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th><th scope="col"></th>
<th scope="col"></th><th scope="col"></th>
</tr>
</thead>
<tbody>
{pressure.map((r) => (
<tr key={r.measuredAt}>
<th scope="row">{r.measuredAt.slice(0, 16)}</th>
<td className="num">{r.systolic ?? '—'}</td>
<td className="num">{r.diastolic ?? '—'}</td>
<td className="num">{r.pulse ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="screen-disclaimer">
</p>
</Screen>
);
}
export default BodyPage;

View File

@@ -0,0 +1,114 @@
.chal-list { display: grid; gap: 0.6rem; }
.chal {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.8rem 0.95rem;
}
.chal-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.8rem;
}
.chal-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.45;
}
.chal-kind {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.chal-dates {
margin-top: 0.25rem;
font-size: 0.72rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.chal-bar {
margin-top: 0.55rem;
height: 6px;
background: var(--surface-0);
border-radius: 999px;
overflow: hidden;
}
.chal-fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.5s var(--ease);
}
.chal-pct {
margin-top: 0.22rem;
font-size: 0.72rem;
color: var(--text-secondary);
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Race predictions ---------------------------------------------------------- */
.race-list {
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
background: var(--surface-1);
margin-bottom: 1.2rem;
}
.race-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border);
}
.race-row:last-child { border-bottom: none; }
.race-name {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.88rem;
color: var(--text-primary);
font-weight: 550;
}
.race-pace { font-size: 0.72rem; color: var(--text-muted); font-weight: 400; }
.race-time {
font-size: 1.15rem;
font-weight: 660;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
/* Devices ------------------------------------------------------------------- */
.dev-list { display: grid; gap: 0.6rem; }
.dev {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.8rem 0.95rem;
}
.dev-name { font-size: 0.9rem; font-weight: 620; color: var(--text-primary); }
.dev-meta { margin-top: 0.3rem; font-size: 0.74rem; color: var(--text-muted); line-height: 1.7; }
@media (prefers-reduced-motion: reduce) {
.chal-fill { transition: none; }
}

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;

View File

@@ -1,5 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
import {
apiClient, Activity, DailySeries, errorMessage, HealthDay,
} from '../services/api';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './Daily.css';
import Screen from '../components/Screen';
@@ -117,17 +120,22 @@ function DailyPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [onlyRecorded, setOnlyRecorded] = useState(true);
const [series, setSeries] = useState<DailySeries>({});
const load = useCallback(async (target: string) => {
setLoading(true);
setError('');
try {
const [summary, acts] = await Promise.all([
const [summary, acts, curves] = await Promise.all([
apiClient.getHealthSummary(target, target),
apiClient.getActivities(target, target),
// Curves are optional: a day synced before they were stored simply
// has none, and the rest of the screen must still render.
apiClient.getDailySeries(target).catch(() => ({} as DailySeries)),
]);
setDay(summary[0] ?? null);
setActivities(acts);
setSeries(curves);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
@@ -163,6 +171,22 @@ function DailyPage() {
});
};
/* The stored curves are [timestamp, value] pairs; recharts wants rows, and
the axis reads better as clock time than as a full timestamp. */
const curveRows = (points: Array<[string, number]> | undefined) =>
(points ?? []).map(([at, value]) => ({
date: String(at).slice(11, 16),
value,
}));
const CURVES: Array<[string, string, string, 1 | 2 | 3 | 4 | 5]> = [
['heartRate', '心率', 'bpm', 1],
['stress', '压力', '', 2],
['bodyBattery', '身体电量', '', 3],
['respiration', '呼吸频率', '次/分', 4],
['spo2', '血氧', '%', 5],
];
return (
<Screen title="每日数据" backLink>
@@ -245,6 +269,29 @@ function DailyPage() {
);
})}
{CURVES.some(([key]) => (series[key] ?? []).length > 0) && (
<section className="sec">
<h3 className="sec-title">线</h3>
<div className="chart-grid">
{CURVES.filter(([key]) => (series[key] ?? []).length > 0).map(
([key, label, unit, slot]) => (
<Chart
key={key}
title={label}
unit={unit || undefined}
data={curveRows(series[key])}
type="area"
height={180}
series={[{
key: 'value', label, slot, unit: unit || undefined,
}]}
/>
)
)}
</div>
</section>
)}
<section className="sec">
<h3 className="sec-title">

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, Device, errorMessage } from '../services/api';
import Screen from '../components/Screen';
import Skeleton from '../components/Skeleton';
import './Challenges.css';
function DevicesPage() {
const [rows, setRows] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
apiClient
.getDevices()
.then(setRows)
.catch((err) => setError(errorMessage(err, '加载失败')))
.finally(() => setLoading(false));
}, []);
if (loading) return <Screen title="设备" backLink><Skeleton count={3} /></Screen>;
return (
<Screen title="设备" backLink>
{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>
) : (
<div className="dev-list">
{rows.map((d) => (
<div className="dev" key={d.deviceId}>
<div className="dev-name">{d.name || d.model || '未知设备'}</div>
<div className="dev-meta">
{d.model && <> {d.model}<br /></>}
{d.softwareVersion && <> {d.softwareVersion}<br /></>}
{d.serial && <> {d.serial}<br /></>}
{d.lastUsedAt && <> {d.lastUsedAt.slice(0, 16)}</>}
</div>
</div>
))}
</div>
)}
</Screen>
);
}
export default DevicesPage;

View File

@@ -175,6 +175,19 @@ function ExercisePage() {
/>
</section>
<section className="sec">
<div className="trend-tools">
<Link href="/challenges/" className="picker-open picker-open-link">
<span className="picker-open-label"></span>
<span className="mcard-chevron" aria-hidden="true"></span>
</Link>
<Link href="/race/" className="picker-open picker-open-link">
<span className="picker-open-label"></span>
<span className="mcard-chevron" aria-hidden="true"></span>
</Link>
</div>
</section>
<div className="metric-tabs">
{([
['activities', `记录 (${activities.length})`],

View File

@@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton';
import Screen from '../components/Screen';
import { daysAgo, today as todayIso } from '../lib/day';
import './Health.css';
import './Settings.css';
const WINDOW_DAYS = 30;
@@ -18,6 +19,14 @@ const SECTIONS: Array<{ title: string; items: string[] }> = [
{ title: '睡眠', items: ['sleepDuration', 'sleepQuality', 'deepShare', 'remShare'] },
{ title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] },
{ title: '能量', items: ['caloriesBurned', 'activeCalories', 'bmrCalories', 'sedentary'] },
{ title: '身体成分', items: ['weight', 'bodyFat', 'hydration', 'hillScore'] },
];
/* Screens that are not a single metric, so they get their own entries. */
const LINKS: Array<[string, string, string]> = [
['/body/', '身体成分与血压', '体重、体脂、肌肉量、血压记录'],
['/race/', '成绩预测', '5 公里到全马的预测完赛时间'],
['/challenges/', '挑战赛', '徽章挑战与好友挑战'],
];
function BodyAge({ data }: { data: FitnessAge | null }) {
@@ -161,6 +170,21 @@ function HealthPage() {
</section>
))}
<section className="sec">
<h3 className="sec-title"></h3>
<div className="set-rows">
{LINKS.map(([href, label, sub]) => (
<Link href={href} className="set-row" key={href}>
<span className="set-label">
{label}
<span className="set-sub">{sub}</span>
</span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
))}
</div>
</section>
<p className="screen-disclaimer">
</p>

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, RacePrediction } from '../services/api';
import Screen from '../components/Screen';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './MetricDetail.css';
const DISTANCES: Array<[keyof RacePrediction, string, number]> = [
['time5k', '5 公里', 1],
['time10k', '10 公里', 2],
['timeHalf', '半程马拉松', 3],
['timeMarathon', '全程马拉松', 4],
];
const hms = (seconds?: number | null) => {
if (!seconds) return '—';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.round(seconds % 60);
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
};
/** Pace per kilometre for a predicted finish. */
const pace = (seconds: number | null | undefined, km: number) => {
if (!seconds) return '—';
const perKm = seconds / km;
return `${Math.floor(perKm / 60)}:${String(Math.round(perKm % 60)).padStart(2, '0')} /km`;
};
const KM: Record<string, number> = {
time5k: 5, time10k: 10, timeHalf: 21.0975, timeMarathon: 42.195,
};
function RacePage() {
const [rows, setRows] = useState<RacePrediction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
apiClient
.getRacePredictions()
.then(setRows)
.catch((err) => setError(errorMessage(err, '加载失败')))
.finally(() => setLoading(false));
}, []);
if (loading) return <Screen title="成绩预测" backLink><Skeleton count={3} /></Screen>;
const latest = rows[rows.length - 1];
if (!latest) {
return (
<Screen title="成绩预测" backLink>
<div className="screen-empty">
<p>Garmin </p>
<Link href="/sync/" className="button button-fill button-round"></Link>
</div>
</Screen>
);
}
// Minutes rather than seconds on the axis: a marathon in seconds is a
// five-digit number that tells the reader nothing at a glance.
const chartRows = rows.map((r) => ({
date: r.date.slice(5),
time5k: r.time5k ? +(r.time5k / 60).toFixed(1) : null,
time10k: r.time10k ? +(r.time10k / 60).toFixed(1) : null,
timeHalf: r.timeHalf ? +(r.timeHalf / 60).toFixed(1) : null,
timeMarathon: r.timeMarathon ? +(r.timeMarathon / 60).toFixed(1) : null,
}));
return (
<Screen title="成绩预测" backLink subtitle={latest.date}>
{error && <div className="screen-error">{error}</div>}
<div className="race-list">
{DISTANCES.map(([key, label]) => (
<div className="race-row" key={key}>
<div className="race-name">
{label}
<span className="race-pace">
{pace(latest[key] as number | null, KM[key as string])}
</span>
</div>
<div className="race-time">{hms(latest[key] as number | null)}</div>
</div>
))}
</div>
{rows.length > 1 && (
<section className="sec">
<Chart
title="预测成绩变化"
unit="分钟"
data={chartRows}
type="line"
height={220}
series={DISTANCES.map(([key, label, slot]) => ({
key: key as string, label, slot: slot as 1 | 2 | 3 | 4,
unit: '分钟', decimals: 1,
}))}
footer="向下走表示预测成绩在变快。"
/>
</section>
)}
<p className="screen-disclaimer">
Garmin VOmax
</p>
</Screen>
);
}
export default RacePage;

View File

@@ -261,6 +261,16 @@ function SettingsPage() {
</div>
</section>
<section className="sec">
<h3 className="sec-title"></h3>
<div className="set-rows">
<Link href="/devices/" className="set-row">
<span className="set-label"></span>
<span className="set-chevron" aria-hidden="true"></span>
</Link>
</div>
</section>
{FEATURES.ai && models.length > 0 && (
<section className="sec">
<h3 className="sec-title">AI </h3>

View File

@@ -8,6 +8,10 @@ import MetricDetailPage from './pages/MetricDetailPage';
import ActivityDetailPage from './pages/ActivityDetailPage';
import BodyAgePage from './pages/BodyAgePage';
import RatingBasisPage from './pages/RatingBasisPage';
import BodyPage from './pages/BodyPage';
import RacePage from './pages/RacePage';
import ChallengesPage from './pages/ChallengesPage';
import DevicesPage from './pages/DevicesPage';
import ExercisePage from './pages/ExercisePage';
import SleepPage from './pages/SleepPage';
import SyncPage from './pages/SyncPage';
@@ -34,6 +38,10 @@ const SCREENS: Router.RouteParameters[] = [
{ path: '/activity/:id/', component: ActivityDetailPage },
{ path: '/body-age/', component: BodyAgePage },
{ path: '/rating-basis/', component: RatingBasisPage },
{ path: '/body/', component: BodyPage },
{ path: '/race/', component: RacePage },
{ path: '/challenges/', component: ChallengesPage },
{ path: '/devices/', component: DevicesPage },
{ path: '/sync/', component: SyncPage },
{ path: '/settings/', component: SettingsPage },
{ path: '/login/', component: LoginPage },

View File

@@ -58,6 +58,13 @@ export interface HealthDay {
sleepStressAvg: number | null;
trainingReadiness: number | null;
vo2max: number | null;
hillScore: number | null;
hydrationMl: number | null;
hydrationGoalMl: number | null;
sweatLossMl: number | null;
weightKg: number | null;
bodyFatPct: number | null;
bmi: number | null;
enduranceScore: number | null;
sleep: SleepDetail | null;
}
@@ -211,11 +218,65 @@ export interface FitnessAge {
export interface DetailSyncStatus {
running: boolean;
/** Which part of the backfill is running: 运动详情 / 每日曲线. */
stage?: string | null;
done: number;
total: number;
error?: string | null;
}
export interface BodyCompositionDay {
date: string;
weightKg: number | null;
bmi: number | null;
bodyFatPct: number | null;
bodyWaterPct: number | null;
boneMassKg: number | null;
muscleMassKg: number | null;
physiqueRating: number | null;
visceralFat: number | null;
metabolicAge: number | null;
}
export interface BloodPressureReading {
measuredAt: string;
systolic: number | null;
diastolic: number | null;
pulse: number | null;
note: string | null;
}
/** Predicted finishing times, in seconds. */
export interface RacePrediction {
date: string;
time5k: number | null;
time10k: number | null;
timeHalf: number | null;
timeMarathon: number | null;
}
/** [timestamp, value] pairs, thinned to at most 240 points per day. */
export type DailySeries = Record<string, Array<[string, number]>>;
export interface Challenge {
uuid: string;
kind: string;
name: string | null;
status: string | null;
startDate: string | null;
endDate: string | null;
payload: Record<string, any>;
}
export interface Device {
deviceId: string;
name: string | null;
model: string | null;
serial: string | null;
softwareVersion: string | null;
lastUsedAt: string | null;
}
export interface AutoSyncStatus {
enabled: boolean;
intervalSeconds: number;
@@ -511,6 +572,44 @@ class ApiClient {
return data;
}
async getBodyComposition(startDate?: string, endDate?: string) {
const { data } = await this.client.get<BodyCompositionDay[]>(
'/health/body-composition', this.range(startDate, endDate)
);
return data;
}
async getBloodPressure() {
const { data } = await this.client.get<BloodPressureReading[]>(
'/health/blood-pressure'
);
return data;
}
async getRacePredictions() {
const { data } = await this.client.get<RacePrediction[]>(
'/health/race-predictions'
);
return data;
}
async getDailySeries(date: string) {
const { data } = await this.client.get<DailySeries>(
'/health/series', { params: { date } }
);
return data;
}
async getChallenges() {
const { data } = await this.client.get<Challenge[]>('/health/challenges');
return data;
}
async getDevices() {
const { data } = await this.client.get<Device[]>('/health/devices');
return data;
}
async getBadges() {
const { data } = await this.client.get<Badge[]>('/health/badges');
return data;