按指定结构调整导航:每日归入趋势、睡眠归入健康、同步归入设置, 它们是各自 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>
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
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;
|