Files
GarminHealthLab/client/src/pages/Sleep.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

186 lines
6.6 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 } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile';
import Skeleton from '../components/Skeleton';
import './Pages.css';
const RANGES = [7, 14, 30, 90];
const H = 3600;
function avg(values: Array<number | null | undefined>): number | null {
const present = values.filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) / present.length : null;
}
function Sleep() {
const [days, setDays] = useState<HealthDay[]>([]);
// 14 by default: the stacked chart needs bars wide enough to read the
// thinnest stage and to give hover a ~24px hit target. Longer windows stay
// available for the trend, where density matters less.
const [range, setRange] = useState(14);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
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 nights = days.filter((d) => d.sleepDuration != null);
// Stage seconds are converted to hours here so the stacked bar and the
// duration chart share one y-scale — a chart never carries two scales.
const rows = nights.map((d) => ({
date: d.date.slice(5),
deep: d.sleep?.deepSeconds != null ? d.sleep.deepSeconds / H : null,
light: d.sleep?.lightSeconds != null ? d.sleep.lightSeconds / H : null,
rem: d.sleep?.remSeconds != null ? d.sleep.remSeconds / H : null,
awake: d.sleep?.awakeSeconds != null ? d.sleep.awakeSeconds / H : null,
quality: d.sleepQuality,
spo2: d.sleepSpo2Avg,
respiration: d.sleepRespirationAvg,
stress: d.sleepStressAvg,
}));
const avgDeep = avg(rows.map((r) => r.deep));
const avgRem = avg(rows.map((r) => r.rem));
const avgLight = avg(rows.map((r) => r.light));
const avgAwake = avg(rows.map((r) => r.awake));
const avgDuration = avg(nights.map((d) => d.sleepDuration));
const avgQuality = avg(nights.map((d) => d.sleepQuality));
const totalStages = [avgDeep, avgLight, avgRem].reduce<number>(
(sum, v) => sum + (v ?? 0), 0
);
const share = (v: number | null) =>
v == null || totalStages === 0 ? undefined : `${Math.round((v / totalStages) * 100)}%`;
const hrs = (v: number | null, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d);
return (
<div className="page">
<header className="page-head">
<div>
<h2></h2>
<p className="subtitle"></p>
</div>
<div className="range-tabs">
{RANGES.map((r) => (
<button
key={r}
className={`range-tab ${r === range ? 'active' : ''}`}
onClick={() => setRange(r)}
>
{r}
</button>
))}
</div>
</header>
{error && <div className="error-message">{error}</div>}
{loading && (
<>
<Skeleton count={6} />
<div style={{ height: '1rem' }} />
<Skeleton count={2} variant="chart" />
</>
)}
{!loading && !error && nights.length === 0 && (
<div className="empty-state">
<p></p>
<Link to="/sync" className="btn btn-primary"></Link>
</div>
)}
{!loading && !error && nights.length > 0 && (
<>
<section className="section">
<h3 className="section-title">{nights.length} </h3>
<div className="tile-grid">
<StatTile label="总时长" value={hrs(avgDuration)} unit="小时" />
<StatTile label="睡眠评分" value={hrs(avgQuality, 0)} unit="/100" />
<StatTile label="深睡" value={hrs(avgDeep)} unit="小时" detail={share(avgDeep)} />
<StatTile label="浅睡" value={hrs(avgLight)} unit="小时" detail={share(avgLight)} />
<StatTile label="REM" value={hrs(avgRem)} unit="小时" detail={share(avgRem)} />
<StatTile label="夜间清醒" value={hrs(avgAwake)} unit="小时" />
</div>
</section>
<section className="section">
<div className="chart-grid one-col">
<Chart
title="睡眠分期"
unit="小时"
subtitle="每晚各阶段时长堆叠;总高度即当晚睡眠总时长"
data={rows}
type="stacked-bar"
height={280}
series={[
{ key: 'deep', label: '深睡', slot: 1, unit: '小时', decimals: 1 },
{ key: 'light', label: '浅睡', slot: 2, unit: '小时', decimals: 1 },
{ key: 'rem', label: 'REM', slot: 3, unit: '小时', decimals: 1 },
{ key: 'awake', label: '清醒', slot: 4, unit: '小时', decimals: 1 },
]}
footer="成人参考:深睡约占 1323%REM 约占 2025%。"
/>
</div>
<div className="chart-grid">
<Chart
title="睡眠评分"
unit="/100"
data={rows}
type="area"
series={[{ key: 'quality', label: '评分', slot: 1 }]}
/>
<Chart
title="夜间血氧"
unit="%"
data={rows}
type="line"
series={[{ key: 'spo2', label: '血氧', slot: 1, unit: '%', decimals: 1 }]}
/>
<Chart
title="夜间呼吸频率"
unit="次/分"
data={rows}
type="line"
series={[
{ key: 'respiration', label: '呼吸', slot: 1, unit: '次/分', decimals: 1 },
]}
/>
<Chart
title="睡眠压力"
data={rows}
type="line"
series={[{ key: 'stress', label: '压力', slot: 1, decimals: 1 }]}
/>
</div>
</section>
</>
)}
</div>
);
}
export default Sleep;