Files
GarminHealthLab/client/src/pages/Daily.tsx
ericwyuan 757afdc941 [阶段9] 界面打磨:hero 圆环、sparkline、骨架屏、动效
参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、
Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。

hero 区(每个视图只有一个 hero 数字):
- 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记,
  而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少
- 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器

sparkline(12 点,卡片内嵌):
- 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手
- null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据

骨架屏取代转圈:
- 形状与将要出现的内容一致,数据到位时布局不跳
- 用扫光而非闪烁,读起来像进度而不是元素在求关注

动效:
- 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来
- 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩
- 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 ——
  引起不适的是位移本身,不是它持续多久

深色改为冷调(借鉴参考项目):
- 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App
- 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色
  全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、
  次要 7.98、弱化 4.38、按钮白字 6.63)

fix(a11y): 大号独立数字不应使用 tabular-nums
- 等宽数字让每个字形都占 0 的宽度,大字号下显得松散;
  等宽只保留给需要纵向对齐的表格列与坐标轴刻度

fix: 数字滚动动画吞掉了小数位
- StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次,
  距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals

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

313 lines
11 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
import Skeleton from '../components/Skeleton';
import './Daily.css';
import './Pages.css';
/** Every stored metric, grouped the way the device groups them. */
interface Field {
key: keyof HealthDay | 'sleepDeep' | 'sleepLight' | 'sleepRem' | 'sleepAwake';
label: string;
unit?: string;
/** Convert the raw stored value for display. */
transform?: (v: number) => number;
decimals?: number;
hint?: string;
}
const SECONDS_TO_HOURS = (v: number) => v / 3600;
const SECONDS_TO_MINUTES = (v: number) => v / 60;
const GROUPS: Array<{ title: string; fields: Field[] }> = [
{
title: '活动',
fields: [
{ key: 'steps', label: '步数', unit: '步' },
{ key: 'stepGoal', label: '步数目标', unit: '步' },
{ key: 'distanceMeters', label: '距离', unit: 'km', transform: (v) => v / 1000, decimals: 2 },
{ key: 'floorsAscended', label: '爬楼上行', unit: '层' },
{ key: 'floorsDescended', label: '爬楼下行', unit: '层' },
{ key: 'intensityMinutes', label: '强度分钟', unit: '分钟', hint: '中等及以上强度' },
{ key: 'activeSeconds', label: '活动时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
{ key: 'sedentarySeconds', label: '久坐时长', unit: '小时', transform: SECONDS_TO_HOURS, decimals: 1 },
],
},
{
title: '能量',
fields: [
{ key: 'caloriesBurned', label: '总消耗', unit: 'kcal' },
{ key: 'activeCalories', label: '活动消耗', unit: 'kcal' },
{ key: 'bmrCalories', label: '基础代谢', unit: 'kcal' },
],
},
{
title: '心率',
fields: [
{ key: 'heartRate', label: '静息心率', unit: 'bpm' },
{ key: 'heartRateMin', label: '最低心率', unit: 'bpm' },
{ key: 'heartRateMax', label: '最高心率', unit: 'bpm' },
{ key: 'heartRateVariability', label: '心率变异性', unit: 'ms', decimals: 1, hint: 'HRV反映恢复情况' },
],
},
{
title: '压力与身体电量',
fields: [
{ key: 'stress', label: '平均压力' },
{ key: 'stressMax', label: '最高压力' },
{ key: 'bodyBatteryHigh', label: '身体电量最高' },
{ key: 'bodyBatteryLow', label: '身体电量最低' },
{ key: 'bodyBatteryCharged', label: '当日充能' },
{ key: 'bodyBatteryDrained', label: '当日消耗' },
],
},
{
title: '睡眠',
fields: [
{ key: 'sleepDuration', label: '总时长', unit: '小时', decimals: 1 },
{ key: 'sleepQuality', label: '睡眠评分', unit: '/100' },
{ key: 'sleepDeep', label: '深睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
{ key: 'sleepLight', label: '浅睡', unit: '分钟', transform: SECONDS_TO_MINUTES },
{ key: 'sleepRem', label: 'REM', unit: '分钟', transform: SECONDS_TO_MINUTES },
{ key: 'sleepAwake', label: '夜间清醒', unit: '分钟', transform: SECONDS_TO_MINUTES },
{ key: 'sleepSpo2Avg', label: '睡眠血氧', unit: '%', decimals: 1 },
{ key: 'sleepRespirationAvg', label: '睡眠呼吸', unit: '次/分', decimals: 1 },
{ key: 'sleepStressAvg', label: '睡眠压力', decimals: 1 },
],
},
{
title: '血氧与呼吸',
fields: [
{ key: 'spo2Avg', label: '平均血氧', unit: '%', decimals: 1 },
{ key: 'spo2Min', label: '最低血氧', unit: '%' },
{ key: 'respirationAvg', label: '平均呼吸', unit: '次/分', decimals: 1 },
{ key: 'respirationMin', label: '最低呼吸', unit: '次/分', decimals: 1 },
{ key: 'respirationMax', label: '最高呼吸', unit: '次/分', decimals: 1 },
],
},
{
title: '训练',
fields: [
{ key: 'trainingReadiness', label: '训练准备度', unit: '/100' },
{ key: 'vo2max', label: 'VO2max', decimals: 1 },
{ key: 'enduranceScore', label: '耐力分' },
],
},
];
const ACTIVITY_LABEL: Record<string, string> = {
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
indoor_cycling: '室内骑行', treadmill_running: '跑步机',
};
function valueOf(day: HealthDay, key: Field['key']): number | null {
if (key === 'sleepDeep') return day.sleep?.deepSeconds ?? null;
if (key === 'sleepLight') return day.sleep?.lightSeconds ?? null;
if (key === 'sleepRem') return day.sleep?.remSeconds ?? null;
if (key === 'sleepAwake') return day.sleep?.awakeSeconds ?? null;
const v = (day as any)[key];
return typeof v === 'number' ? v : null;
}
const iso = (d: Date) => d.toISOString().slice(0, 10);
function Daily() {
const [date, setDate] = useState(() => iso(new Date()));
const [day, setDay] = useState<HealthDay | null>(null);
const [activities, setActivities] = useState<Activity[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [onlyRecorded, setOnlyRecorded] = useState(true);
const load = useCallback(async (target: string) => {
setLoading(true);
setError('');
try {
const [summary, acts] = await Promise.all([
apiClient.getHealthSummary(target, target),
apiClient.getActivities(target, target),
]);
setDay(summary[0] ?? null);
setActivities(acts);
} catch (err: any) {
setError(errorMessage(err, '加载失败'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(date); }, [date, load]);
const shift = (delta: number) => {
const d = new Date(date);
d.setDate(d.getDate() + delta);
if (d > new Date()) return;
setDate(iso(d));
};
const recorded = useMemo(() => {
if (!day) return 0;
return GROUPS.reduce(
(n, g) => n + g.fields.filter((f) => valueOf(day, f.key) != null).length, 0
);
}, [day]);
const totalFields = GROUPS.reduce((n, g) => n + g.fields.length, 0);
const isToday = date === iso(new Date());
const fmt = (f: Field, raw: number) => {
const v = f.transform ? f.transform(raw) : raw;
const decimals = f.decimals ?? 0;
return v.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
});
};
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle">
{day
? `已记录 ${recorded} / ${totalFields} 项指标`
: '该日无数据'}
</p>
</div>
<div className="day-nav">
<button className="btn btn-plain" onClick={() => shift(-1)} aria-label="前一天">
</button>
<input
type="date"
className="day-input"
value={date}
max={iso(new Date())}
onChange={(e) => e.target.value && setDate(e.target.value)}
/>
<button
className="btn btn-plain"
onClick={() => shift(1)}
disabled={isToday}
aria-label="后一天"
>
</button>
</div>
</header>
{error && <div className="error-message">{error}</div>}
{loading && <Skeleton count={8} variant="row" />}
{!loading && !error && !day && (
<div className="empty-state">
<p>{date} </p>
</div>
)}
{!loading && !error && day && (
<>
<label className="toggle-row">
<input
type="checkbox"
checked={onlyRecorded}
onChange={(e) => setOnlyRecorded(e.target.checked)}
/>
</label>
{GROUPS.map((g) => {
const fields = onlyRecorded
? g.fields.filter((f) => valueOf(day, f.key) != null)
: g.fields;
if (fields.length === 0) return null;
return (
<section className="section" key={g.title}>
<h3 className="section-title">
{g.title}
<span className="section-count">{fields.length} </span>
</h3>
<dl className="metric-list">
{fields.map((f) => {
const raw = valueOf(day, f.key);
return (
<div className="metric-row" key={String(f.key)}>
<dt>
{f.label}
{f.hint && <span className="metric-hint">{f.hint}</span>}
</dt>
<dd>
{raw == null ? (
<span className="metric-empty"></span>
) : (
<>
<span className="metric-value">{fmt(f, raw)}</span>
{f.unit && <span className="metric-unit">{f.unit}</span>}
</>
)}
</dd>
</div>
);
})}
</dl>
</section>
);
})}
<section className="section">
<h3 className="section-title">
<span className="section-count">{activities.length} </span>
</h3>
{activities.length === 0 ? (
<p className="placeholder"></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>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={a.id}>
<th scope="row">{a.start_time?.slice(11, 16)}</th>
<td>
{ACTIVITY_LABEL[a.activity_type] ??
a.activity_type?.replace(/_/g, ' ')}
</td>
<td className="num">
{a.duration != null ? `${Math.round(a.duration / 60)}` : '—'}
</td>
<td className="num">
{a.distance ? `${(a.distance / 1000).toFixed(2)} km` : '—'}
</td>
<td className="num">
{a.calories != null ? Math.round(a.calories) : '—'}
</td>
<td className="num">{a.heart_rate_average ?? '—'}</td>
<td className="num">{a.heart_rate_max ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
);
}
export default Daily;