Files
GarminHealthLab/client/src/pages/TrendsPage.tsx
ericwyuan fd9b610810 [阶段10.1] Tab 改为 今日/健康/趋势/运动/设置
按指定结构调整导航:每日归入趋势、睡眠归入健康、同步归入设置,
它们是各自 Tab 的详情视图而非独立目的地。新增「运动」Tab。

运动页(新):
- 近 30 天运动时长/距离/消耗,以及今日强度分钟
- 项目分布:按累计时长排序,横条表示占比。只用一个色相 —— 每个项目
  一种颜色会让人以为分类另有含义
- 强度分钟趋势图,附 WHO 每周 150 分钟的参考
- 记录 / 个人纪录 / 奖励三个分页,运动记录 174 条

fix(nav): 五个 Tab 全部加载了同一个页面
- browserHistory 开在每个 View 上,导致它们都去读浏览器地址栏(当时是
  "/")而不是各自的 url,五个 Tab 渲染出五份「今日」
- 浏览器历史只绑定主 View,其余 Tab 用自己的 url 独立加载

fix(nav): 导航栏右侧对所有页面塞同一组图标
- 睡眠/同步/设置三个图标出现在每个页面上,其中两个在多数页面无意义。
  改为按页面声明:趋势 → 每日,健康 → 睡眠,设置 → 同步

fix(viz): 图表标题在窄栏里竖排
- 标题与「看数据」按钮争抢宽度,短标题被压成竖排字符。
  标题允许收缩省略,按钮不收缩

fix: 未映射的运动类型直接暴露 snake_case
- 补充登山/划船/椭圆机等映射;仍未覆盖的转为空格分词并首字母大写,
  而不是把 Garmin 的原始 key 摆给用户看

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 23:52:18 +08:00

345 lines
12 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, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import {
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
} from '../lib/aggregate';
import Screen from '../components/Screen';
const RANGES = [
{ days: 30, label: '近一月' },
{ days: 91, label: '近一季' },
{ days: 182, label: '近半年' },
{ days: 365, label: '近一年' },
{ days: 730, label: '近两年' },
];
const HIDDEN_KEY = 'ghl_hidden_metrics';
/** Each group is one chart. Metrics only share a chart when they share a
* scale and a unit — a chart never carries two y-scales. */
interface MetricGroup {
id: string;
label: string;
unit?: string;
type: 'line' | 'bar' | 'area';
series: Series[];
/** Optional transform, e.g. metres to kilometres. */
scale?: Record<string, number>;
note?: string;
}
const GROUPS: MetricGroup[] = [
{
id: 'steps', label: '步数', unit: '步', type: 'bar',
series: [{ key: 'steps', label: '步数', slot: 1, unit: '步' }],
},
{
id: 'distance', label: '距离', unit: 'km', type: 'bar',
scale: { distanceMeters: 1 / 1000 },
series: [{ key: 'distanceMeters', label: '距离', slot: 1, unit: 'km', decimals: 2 }],
},
{
id: 'calories', label: '能量消耗', unit: 'kcal', type: 'bar',
series: [
{ key: 'bmrCalories', label: '基础代谢', slot: 1, unit: 'kcal' },
{ key: 'activeCalories', label: '活动消耗', slot: 2, unit: 'kcal' },
],
note: '两者相加即当日总消耗。',
},
{
id: 'heart', label: '心率', unit: 'bpm', type: 'line',
series: [
{ key: 'heartRate', label: '静息', slot: 1, unit: 'bpm' },
{ key: 'heartRateMax', label: '最高', slot: 2, unit: 'bpm' },
{ key: 'heartRateMin', label: '最低', slot: 3, unit: 'bpm' },
],
},
{
id: 'hrv', label: '心率变异性', unit: 'ms', type: 'area',
series: [{ key: 'heartRateVariability', label: 'HRV', slot: 1, unit: 'ms', decimals: 1 }],
note: 'HRV 反映自主神经恢复情况,持续偏低常与压力或训练过量相关。',
},
{
id: 'stress', label: '压力', type: 'line',
series: [
{ key: 'stress', label: '平均', slot: 1 },
{ key: 'stressMax', label: '峰值', slot: 2 },
],
},
{
id: 'battery', label: '身体电量', type: 'line',
series: [
{ key: 'bodyBatteryHigh', label: '最高', slot: 1 },
{ key: 'bodyBatteryLow', label: '最低', slot: 2 },
],
},
{
id: 'sleep', label: '睡眠时长', unit: '小时', type: 'area',
series: [{ key: 'sleepDuration', label: '时长', slot: 1, unit: '小时', decimals: 1 }],
},
{
id: 'spo2', label: '血氧', unit: '%', type: 'line',
series: [
{ key: 'spo2Avg', label: '平均', slot: 1, unit: '%', decimals: 1 },
{ key: 'spo2Min', label: '最低', slot: 2, unit: '%' },
],
},
{
id: 'respiration', label: '呼吸频率', unit: '次/分', type: 'line',
series: [
{ key: 'respirationAvg', label: '平均', slot: 1, unit: '次/分', decimals: 1 },
{ key: 'respirationMax', label: '最高', slot: 2, unit: '次/分', decimals: 1 },
{ key: 'respirationMin', label: '最低', slot: 3, unit: '次/分', decimals: 1 },
],
},
{
id: 'floors', label: '爬楼', unit: '层', type: 'bar',
series: [{ key: 'floorsAscended', label: '上行', slot: 1, unit: '层' }],
},
{
id: 'intensity', label: '强度分钟', unit: '分钟', type: 'bar',
series: [{ key: 'intensityMinutes', label: '强度分钟', slot: 1, unit: '分钟' }],
},
{
id: 'sedentary', label: '久坐与活动时长', unit: '小时', type: 'bar',
scale: { sedentarySeconds: 1 / 3600, activeSeconds: 1 / 3600 },
series: [
{ key: 'sedentarySeconds', label: '久坐', slot: 1, unit: '小时', decimals: 1 },
{ key: 'activeSeconds', label: '活动', slot: 2, unit: '小时', decimals: 1 },
],
},
{
id: 'training', label: '训练准备度', unit: '/100', type: 'area',
series: [{ key: 'trainingReadiness', label: '准备度', slot: 1 }],
},
{
id: 'endurance', label: '耐力分', type: 'area',
series: [{ key: 'enduranceScore', label: '耐力分', slot: 1 }],
},
];
function summarise(values: Array<number | null | undefined>) {
const present = values.filter((v): v is number => v != null);
if (!present.length) return null;
const sorted = [...present].sort((a, b) => a - b);
const mean = present.reduce((a, b) => a + b, 0) / present.length;
const mid = Math.floor(present.length / 2);
const delta =
present.length > 1
? present.slice(mid).reduce((a, b) => a + b, 0) / (present.length - mid) -
present.slice(0, mid).reduce((a, b) => a + b, 0) / Math.max(mid, 1)
: 0;
return { mean, min: sorted[0], max: sorted[sorted.length - 1], delta };
}
const fmt = (v: number) => {
const abs = Math.abs(v);
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
};
function TrendsPage() {
const [days, setDays] = useState<HealthDay[]>([]);
const [range, setRange] = useState(365);
const [granularity, setGranularity] = useState<Granularity | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Which charts are hidden. Persisted: a selection that resets on every
// reload is not really a preference.
const [hidden, setHidden] = useState<Set<string>>(() => {
try {
return new Set<string>(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]'));
} catch {
return new Set<string>();
}
});
useEffect(() => {
localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hidden]));
}, [hidden]);
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const end = new Date();
const start = new Date(end.getTime() - (range - 1) * 86400000);
setDays(
await apiClient.getHealthSummary(
start.toISOString().slice(0, 10),
end.toISOString().slice(0, 10)
)
);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
};
load();
}, [range]);
const effective = granularity ?? suggestGranularity(days.length);
const visible = GROUPS.filter((g) => !hidden.has(g.id));
// Aggregated once for every metric, so all charts read the same slice — a
// filter row that scoped only some of them would be misleading.
const allKeys = useMemo(() => GROUPS.flatMap((g) => g.series.map((s) => s.key)), []);
const buckets = useMemo(
() => aggregate(days, effective, allKeys),
[days, effective, allKeys]
);
const rowsFor = (group: MetricGroup) =>
buckets.map((b) => {
const row: Record<string, any> = { date: b.label };
for (const s of group.series) {
const raw = b.values[s.key];
const factor = group.scale?.[s.key];
row[s.key] = raw == null ? null : factor ? raw * factor : raw;
}
return row;
});
const toggle = (id: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const granLabel =
GRANULARITIES.find((g) => g.id === effective)?.label.replace('每', '') ?? '';
return (
<Screen title="趋势" right={<Link href="/daily/" iconIos="f7:calendar" tooltip="每日数据" />}>
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
{RANGES.map((r) => (
<button key={r.days} className={r.days === range ? 'on' : ''}
onClick={() => setRange(r.days)}>{r.label}</button>
))}
</div>
</div>
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
{GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => (
<button key={g.id} className={g.id === effective ? 'on' : ''}
onClick={() => setGranularity(g.id)}>{g.label}</button>
))}
</div>
</div>
<section className="metric-picker">
<div className="picker-head">
<span className="control-label"></span>
<div className="picker-actions">
<button className="link-button" onClick={() => setHidden(new Set())}>
</button>
<button
className="link-button"
onClick={() => setHidden(new Set(GROUPS.map((g) => g.id)))}
>
</button>
</div>
</div>
<div className="picker-chips">
{GROUPS.map((g) => {
const on = !hidden.has(g.id);
return (
<button
key={g.id}
className={`chip ${on ? 'on' : 'off'}`}
onClick={() => toggle(g.id)}
aria-pressed={on}
>
{/* A mark, not colour alone, carries the on/off state. */}
<span className="chip-mark" aria-hidden="true">{on ? '✓' : '+'}</span>
{g.label}
</button>
);
})}
</div>
</section>
{error && <div className="screen-error">{error}</div>}
{loading && <Skeleton count={6} variant="chart" />}
{!loading && !error && visible.length === 0 && (
<p className="screen-note"></p>
)}
{!loading && !error && visible.length > 0 && (
<div className="chart-grid">
{visible.map((g) => {
const rows = rowsFor(g);
const primary = g.series[0];
const s = summarise(rows.map((r) => r[primary.key]));
/* Bars and areas both encode magnitude by extent, so both must
start at zero — which makes a year of monthly step averages,
all between 9.6k and 12.7k, render as near-identical shapes and
hides exactly the change the reader came for. Once days are
bucketed the question is "how is this trending", and that is a
line's job: it encodes position rather than extent, so a
non-zero axis is legitimate and the variation becomes visible.
Dense daily views switch for the same reason plus hit size. */
const aggregated = effective !== 'day';
const type =
aggregated || (g.type === 'bar' && rows.length > 90)
? ('line' as const)
: g.type;
const meanWord =
effective !== 'day' && isCumulative(primary.key) ? '日均' : '平均';
return (
<Chart
key={g.id}
title={g.label}
unit={g.unit}
subtitle={
effective === 'day'
? undefined
: `每点为一个${granLabel}周期的日均值,共 ${rows.length} 个周期`
}
data={rows}
type={type}
series={g.series}
height={210}
footer={
s ? (
<span className="chart-stats">
<span>{meanWord} <b>{fmt(s.mean)}</b></span>
<span> <b>{fmt(s.min)}</b></span>
<span> <b>{fmt(s.max)}</b></span>
<span> <b>{s.delta >= 0 ? '+' : ''}{fmt(s.delta)}</b></span>
</span>
) : (
g.note
)
}
/>
);
})}
</div>
)}
</Screen>
);
}
export default TrendsPage;