Files
GarminHealthLab/client/src/pages/HealthPage.tsx
ericwyuan f1319a6171 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>
2026-08-24 04:16:33 +08:00

196 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Link, f7 } from 'framework7-react';
import { apiClient, errorMessage, FitnessAge, HealthDay } from '../services/api';
import { METRICS, metricHref } from '../lib/metrics';
import MetricCard from '../components/charts/MetricCard';
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;
/* Sections name metric ids; the labels, units and accessors come from the
registry so 今日 / 健康 / 趋势 cannot disagree about what a metric is. */
const SECTIONS: Array<{ title: string; items: string[] }> = [
{ title: '身体指标', items: ['heartRate', 'heartRateVariability', 'respirationAvg', 'spo2Avg'] },
{ title: '恢复', items: ['bodyBatteryHigh', 'stress', 'trainingReadiness', 'enduranceScore'] },
{ 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 }) {
if (!data) return null;
const go = () => f7.views.current.router.navigate('/body-age/');
if (data.value == null) {
return (
<section className="sec">
<h3 className="sec-title"></h3>
<button className="bodyage bodyage-empty" onClick={go} type="button">
<span className="bodyage-missing">
{data.missing.join('、')}
</span>
<span className="mcard-chevron" aria-hidden="true"></span>
</button>
</section>
);
}
const delta = data.delta ?? 0;
return (
<section className="sec">
<h3 className="sec-title"></h3>
<button className="bodyage" onClick={go} type="button">
<div className="bodyage-main">
<span className="bodyage-value">{data.value}</span>
<span className="bodyage-unit"></span>
</div>
<div className="bodyage-side">
<span className={`bodyage-delta ${delta < 0 ? 'good' : delta > 0 ? 'warn' : ''}`}>
{delta === 0
? '与实际年龄相当'
: `比实际年龄${delta < 0 ? '年轻' : '大'} ${Math.abs(delta)}`}
</span>
<span className="bodyage-note">
{data.chronologicalAge} ·
</span>
</div>
<span className="mcard-chevron" aria-hidden="true"></span>
</button>
</section>
);
}
function HealthPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [bodyAge, setBodyAge] = useState<FitnessAge | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setDays(
await apiClient.getHealthSummary(
daysAgo(WINDOW_DAYS - 1), todayIso()
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
// Body age is a separate, optional read: an empty profile must not stop
// the rest of the page from rendering.
apiClient.getFitnessAge().then(setBodyAge).catch(() => setBodyAge(null));
}, []);
if (loading) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<Skeleton count={8} />
</Screen>
);
}
if (error) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<div className="screen-error">{error}</div>
</Screen>
);
}
if (days.length === 0) {
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round">
Garmin
</Link>
</div>
</Screen>
);
}
// The most recent day that actually recorded a given metric — showing "—"
// because today's sleep has not synced yet would hide data that exists.
const latest = (pick: (d: HealthDay) => number | null) => {
for (let i = days.length - 1; i >= 0; i--) {
const v = pick(days[i]);
if (v != null) return { value: v, date: days[i].date };
}
return { value: null as number | null, date: null as string | null };
};
return (
<Screen title="健康" subtitle="每项指标的最新值与参考区间">
<BodyAge data={bodyAge} />
{SECTIONS.map((section) => (
<section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3>
<div className="mcard-grid">
{section.items.map((id) => {
const def = METRICS[id];
if (!def) return null;
const { value, date } = latest(def.pick);
const stale = date != null && date !== days[days.length - 1].date;
return (
<MetricCard
key={id}
metric={def.range}
label={def.label}
value={value}
unit={def.unit}
decimals={def.decimals}
trend={days.map(def.pick)}
detail={stale ? `最近记录 ${date!.slice(5)}` : undefined}
onClick={() => f7.views.current.router.navigate(metricHref(id))}
/>
);
})}
</div>
</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>
</Screen>
);
}
export default HealthPage;