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; 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) { 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([]); const [range, setRange] = useState(365); const [granularity, setGranularity] = useState(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>(() => { try { return new Set(JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]')); } catch { return new Set(); } }); 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 = { 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 ( }>
范围
{RANGES.map((r) => ( ))}
周期
{GRANULARITIES.filter((g) => g.days <= Math.max(range / 2, 1)).map((g) => ( ))}
显示
{GROUPS.map((g) => { const on = !hidden.has(g.id); return ( ); })}
{error &&
{error}
} {loading && } {!loading && !error && visible.length === 0 && (

所有指标都已隐藏,点上面的标签重新显示。

)} {!loading && !error && visible.length > 0 && (
{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 ( {meanWord} {fmt(s.mean)} {fmt(s.min)} {fmt(s.max)} 后半段 {s.delta >= 0 ? '+' : ''}{fmt(s.delta)} ) : ( g.note ) } /> ); })}
)}
); } export default TrendsPage;