[阶段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>
This commit is contained in:
ericwyuan
2026-08-23 22:48:09 +08:00
parent bfda1cd017
commit 757afdc941
18 changed files with 839 additions and 38 deletions

View File

@@ -0,0 +1,66 @@
.skeleton-group {
display: grid;
gap: 0.75rem;
}
.skeleton-tile {
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
}
.skeleton-chart {
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 1rem;
}
.skeleton-row {
grid-template-columns: 1fr;
gap: 0.5rem;
}
.skeleton {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
position: relative;
overflow: hidden;
}
.skeleton-tile .skeleton { height: 92px; }
.skeleton-chart .skeleton { height: 268px; }
.skeleton-row .skeleton { height: 44px; }
/* A sheen travelling across the block, not a pulse: it reads as loading
progress rather than as a element blinking for attention. */
.skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--text-primary) 6%, transparent),
transparent
);
animation: sheen 1.4s ease-in-out infinite;
}
@keyframes sheen {
to { transform: translateX(100%); }
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after { animation: none; }
}

View File

@@ -0,0 +1,26 @@
import './Skeleton.css';
interface SkeletonProps {
/** How many placeholder blocks to draw. */
count?: number;
variant?: 'tile' | 'chart' | 'row';
}
/**
* Placeholder shaped like the content that is coming.
*
* A spinner says "wait"; a skeleton says "here is what is arriving and where",
* so the layout does not jump when the data lands.
*/
function Skeleton({ count = 4, variant = 'tile' }: SkeletonProps) {
return (
<div className={`skeleton-group skeleton-${variant}`} aria-hidden="true">
{Array.from({ length: count }, (_, i) => (
<div className="skeleton" key={i} style={{ animationDelay: `${i * 90}ms` }} />
))}
<span className="sr-only" role="status"></span>
</div>
);
}
export default Skeleton;

View File

@@ -169,3 +169,36 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--text-primary); color: var(--text-primary);
} }
.viz {
transition: box-shadow 0.2s var(--ease), border-color 0.2s var(--ease);
animation: viz-in 0.45s var(--ease) both;
}
.viz:hover {
box-shadow: var(--shadow-lift);
border-color: var(--border-strong);
}
@keyframes viz-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.chart-grid > .viz:nth-child(2) { animation-delay: 60ms; }
.chart-grid > .viz:nth-child(3) { animation-delay: 120ms; }
.chart-grid > .viz:nth-child(4) { animation-delay: 180ms; }
.chart-grid > .viz:nth-child(n + 5) { animation-delay: 220ms; }
.viz-toggle {
transition: border-color 0.15s var(--ease), color 0.15s var(--ease),
background 0.15s var(--ease);
}
.viz-toggle:hover {
background: var(--surface-0);
}
@media (prefers-reduced-motion: reduce) {
.viz { animation: none; transition: none; }
}

View File

@@ -0,0 +1,25 @@
.ring {
position: relative;
display: grid;
place-items: center;
flex-shrink: 0;
}
.ring svg {
position: absolute;
inset: 0;
}
.ring-fill {
transition: stroke-dashoffset 1.1s cubic-bezier(0.22, 1, 0.36, 1),
stroke 0.3s ease;
}
.ring-center {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
text-align: center;
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { usePrefersReducedMotion } from '../../lib/motion';
import './Ring.css';
interface RingProps {
/** 01; values above 1 are drawn as a full ring plus an overshoot mark. */
progress: number | null;
size?: number;
thickness?: number;
children?: React.ReactNode;
label?: string;
}
/**
* A goal ring.
*
* The track is a lighter step of the fill's own ramp rather than plain grey,
* so the pair reads as one meter at a glance. Progress past 100% keeps the
* ring full and adds a small cap mark instead of wrapping a second lap, which
* would read as a much smaller number than it is.
*/
function Ring({ progress, size = 132, thickness = 11, children, label }: RingProps) {
const reduced = usePrefersReducedMotion();
const [shown, setShown] = useState(reduced ? progress ?? 0 : 0);
useEffect(() => {
if (progress == null) return;
if (reduced) { setShown(progress); return; }
// One frame's delay so the browser paints the empty ring first and the
// transition actually runs.
const id = requestAnimationFrame(() => setShown(progress));
return () => cancelAnimationFrame(id);
}, [progress, reduced]);
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = Math.min(1, Math.max(0, shown));
const offset = circumference * (1 - clamped);
const over = (progress ?? 0) > 1;
return (
<div className="ring" style={{ width: size, height: size }}>
<svg width={size} height={size} role="img" aria-label={label}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--ring-track)"
strokeWidth={thickness}
/>
<circle
className={reduced ? undefined : 'ring-fill'}
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={over ? 'var(--status-good)' : 'var(--accent)'}
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div className="ring-center">{children}</div>
</div>
);
}
export default Ring;

View File

@@ -0,0 +1,22 @@
.spark {
display: block;
overflow: visible;
}
.spark-empty {
display: inline-block;
width: 68px;
height: 22px;
}
/* Draw the line on rather than fading it in — the stroke reveal traces the
shape the reader is about to interpret. */
.spark-path {
stroke-dasharray: 240;
stroke-dashoffset: 240;
animation: spark-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes spark-draw {
to { stroke-dashoffset: 0; }
}

View File

@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { usePrefersReducedMotion } from '../../lib/motion';
import './Sparkline.css';
interface SparklineProps {
values: Array<number | null | undefined>;
/** Points to keep, newest last. */
points?: number;
width?: number;
height?: number;
label?: string;
}
/**
* A tiny trend beside a stat.
*
* The line sits in a de-emphasised ink and only the latest point wears the
* accent, so the sparkline reads as context for the number it accompanies
* rather than competing with it. It is decorative-by-position but not
* decorative-by-meaning, so it carries an accessible label and is hidden from
* the tree only when one is not supplied.
*/
function Sparkline({ values, points = 12, width = 68, height = 22, label }: SparklineProps) {
const reduced = usePrefersReducedMotion();
const geometry = useMemo(() => {
const recent = values.slice(-points);
const present = recent.filter((v): v is number => v != null);
if (present.length < 2) return null;
const min = Math.min(...present);
const max = Math.max(...present);
const span = max - min || 1;
const step = width / Math.max(recent.length - 1, 1);
const coords: Array<[number, number] | null> = recent.map((v, i) =>
v == null ? null : [i * step, height - ((v - min) / span) * (height - 4) - 2]
);
// Nulls break the path rather than being interpolated: a flat segment
// across a day the device recorded nothing would be an invented value.
let d = '';
let pen = false;
for (const c of coords) {
if (!c) { pen = false; continue; }
d += `${pen ? 'L' : 'M'}${c[0].toFixed(1)},${c[1].toFixed(1)} `;
pen = true;
}
const last = [...coords].reverse().find((c): c is [number, number] => c != null);
return { d: d.trim(), last, length: recent.length };
}, [values, points, width, height]);
if (!geometry) return <span className="spark-empty" aria-hidden="true" />;
return (
<svg
className="spark"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<path
className={reduced ? undefined : 'spark-path'}
d={geometry.d}
fill="none"
stroke="var(--spark-ink)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{geometry.last && (
<circle
cx={geometry.last[0]}
cy={geometry.last[1]}
r={2.5}
fill="var(--accent)"
stroke="var(--surface-1)"
strokeWidth={1.5}
/>
)}
</svg>
);
}
export default Sparkline;

View File

@@ -4,12 +4,28 @@
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease),
border-color 0.2s var(--ease);
}
.tile:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
border-color: var(--border-strong);
}
.tile-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.3rem;
min-height: 22px;
} }
.tile-label { .tile-label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0.35rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -20,7 +36,9 @@
font-weight: 650; font-weight: 650;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.15; line-height: 1.15;
font-variant-numeric: tabular-nums; /* Proportional figures on purpose: tabular-nums gives every digit the width
of a zero, which makes a large standalone number look loose. Tabular is
reserved for columns that must align (tables, axis ticks). */
} }
.tile-unit { .tile-unit {
@@ -32,7 +50,7 @@
.tile-meter { .tile-meter {
height: 4px; height: 4px;
background: var(--surface-0); background: var(--ring-track);
border-radius: 999px; border-radius: 999px;
margin-top: 0.55rem; margin-top: 0.55rem;
overflow: hidden; overflow: hidden;
@@ -42,6 +60,7 @@
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
border-radius: 999px; border-radius: 999px;
transition: width 1s var(--ease);
} }
.tile-detail { .tile-detail {
@@ -66,10 +85,29 @@
.tile-grid { .tile-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
/* Cards arrive in sequence rather than all at once, so the eye is led across
the group instead of being hit by the whole grid at the same instant. */
.tile-grid > * {
animation: tile-in 0.42s var(--ease) both;
}
.tile-grid > *:nth-child(1) { animation-delay: 0ms; }
.tile-grid > *:nth-child(2) { animation-delay: 45ms; }
.tile-grid > *:nth-child(3) { animation-delay: 90ms; }
.tile-grid > *:nth-child(4) { animation-delay: 135ms; }
.tile-grid > *:nth-child(5) { animation-delay: 180ms; }
.tile-grid > *:nth-child(6) { animation-delay: 225ms; }
.tile-grid > *:nth-child(n + 7) { animation-delay: 270ms; }
@keyframes tile-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@media (max-width: 560px) { @media (max-width: 560px) {
.tile-grid { .tile-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -78,3 +116,16 @@
font-size: 1.3rem; font-size: 1.3rem;
} }
} }
@media (prefers-reduced-motion: reduce) {
.tile,
.tile-meter-fill {
transition: none;
}
.tile:hover {
transform: none;
}
.tile-grid > * {
animation: none;
}
}

View File

@@ -1,10 +1,12 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { useCountUp } from '../../lib/motion';
import Sparkline from './Sparkline';
import './StatTile.css'; import './StatTile.css';
export type Status = 'good' | 'warning' | 'serious' | 'critical'; export type Status = 'good' | 'warning' | 'serious' | 'critical';
/* Status is carried by an icon plus a label, never by colour alone — the /* Status is carried by an icon plus a label, never by colour alone — the
light-surface status steps are deliberately below 3:1. */ light-surface status steps sit below 3:1 by design. */
const STATUS_ICON: Record<Status, string> = { const STATUS_ICON: Record<Status, string> = {
good: '●', good: '●',
warning: '▲', warning: '▲',
@@ -22,21 +24,36 @@ interface StatTileProps {
statusLabel?: string; statusLabel?: string;
/** 01; draws a goal meter under the value. */ /** 01; draws a goal meter under the value. */
progress?: number | null; progress?: number | null;
/** Recent history for an inline trend. */
trend?: Array<number | null | undefined>;
decimals?: number;
} }
function StatTile({ function StatTile({
label, value, unit, detail, status, statusLabel, progress, label, value, unit, detail, status, statusLabel, progress, trend, decimals = 0,
}: StatTileProps) { }: StatTileProps) {
const numeric = typeof value === 'number' ? value : null;
const animated = useCountUp(numeric);
const display = const display =
value == null value == null
? '—' ? '—'
: typeof value === 'number' : numeric != null
? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) ? (animated ?? numeric).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
})
: value; : value;
return ( return (
<div className="tile"> <div className="tile">
<div className="tile-label">{label}</div> <div className="tile-head">
<span className="tile-label">{label}</span>
{trend && trend.some((v) => v != null) && (
<Sparkline values={trend} label={`${label}近期走势`} />
)}
</div>
<div className="tile-value"> <div className="tile-value">
{display} {display}
{unit && value != null && <span className="tile-unit">{unit}</span>} {unit && value != null && <span className="tile-unit">{unit}</span>}

100
client/src/lib/motion.ts Normal file
View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
/**
* Whether the viewer asked for reduced motion.
*
* Every animation in the app checks this. Vestibular disorders make sweeping
* movement genuinely unpleasant, and the setting is the user telling us so —
* so motion is skipped outright rather than merely shortened.
*/
export function usePrefersReducedMotion(): boolean {
const query = '(prefers-reduced-motion: reduce)';
const [reduced, setReduced] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setReduced(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return reduced;
}
/** Ease-out cubic: fast start, gentle settle — reads as responsive. */
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
/**
* Animate a number from 0 to `value`.
*
* Returns the target immediately when motion is reduced, or when the value is
* not a number — a counter that ticks up to "—" would be nonsense.
*/
export function useCountUp(value: number | null, durationMs = 650): number | null {
const reduced = usePrefersReducedMotion();
const [display, setDisplay] = useState<number | null>(value);
const frame = useRef<number>();
const from = useRef(0);
useEffect(() => {
if (value == null || reduced) {
setDisplay(value);
return;
}
const start = performance.now();
const origin = from.current;
const delta = value - origin;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / durationMs);
setDisplay(origin + delta * easeOut(t));
if (t < 1) frame.current = requestAnimationFrame(tick);
else from.current = value;
};
frame.current = requestAnimationFrame(tick);
return () => {
if (frame.current) cancelAnimationFrame(frame.current);
};
}, [value, durationMs, reduced]);
return display;
}
/**
* Reveal an element once it scrolls into view.
*
* Cards below the fold animating on page load is motion the viewer never sees;
* tying it to visibility means the movement always accompanies the reveal.
*/
export function useReveal<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [shown, setShown] = useState(false);
const reduced = usePrefersReducedMotion();
useEffect(() => {
if (reduced) {
setShown(true);
return;
}
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShown(true);
observer.disconnect();
}
},
{ rootMargin: '0px 0px -40px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [reduced]);
return { ref, shown };
}

View File

@@ -3,6 +3,7 @@ import {
apiClient, Activity, Badge, errorMessage, PersonalRecord, apiClient, Activity, Badge, errorMessage, PersonalRecord,
} from '../services/api'; } from '../services/api';
import StatTile from '../components/charts/StatTile'; import StatTile from '../components/charts/StatTile';
import Skeleton from '../components/Skeleton';
import './Pages.css'; import './Pages.css';
type Tab = 'badges' | 'records' | 'activities'; type Tab = 'badges' | 'records' | 'activities';
@@ -53,7 +54,14 @@ function Achievements() {
load(); load();
}, []); }, []);
if (loading) return <div className="page-loading"></div>; if (loading) {
return (
<div className="page">
<h2></h2>
<Skeleton count={4} />
</div>
);
}
// Badges cluster heavily by year, which is the only grouping that reads. // Badges cluster heavily by year, which is the only grouping that reads.
const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => { const byYear = badges.reduce<Record<string, Badge[]>>((acc, b) => {

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { apiClient, Activity, errorMessage, HealthDay } from '../services/api'; import { apiClient, Activity, errorMessage, HealthDay } from '../services/api';
import Skeleton from '../components/Skeleton';
import './Daily.css'; import './Daily.css';
import './Pages.css'; import './Pages.css';
@@ -198,7 +199,7 @@ function Daily() {
</header> </header>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>} {loading && <Skeleton count={8} variant="row" />}
{!loading && !error && !day && ( {!loading && !error && !day && (
<div className="empty-state"> <div className="empty-state">

View File

@@ -0,0 +1,104 @@
/* Hero -------------------------------------------------------------------- */
.hero {
display: flex;
align-items: center;
gap: 1.75rem;
padding: 1.5rem 1.75rem;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
margin-bottom: 1.75rem;
animation: hero-in 0.5s var(--ease) both;
}
@keyframes hero-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* The one hero figure on the view. Proportional figures, and the app's own
sans — a display face here would read as decoration rather than data. */
.hero-value {
font-size: 2.6rem;
font-weight: 660;
line-height: 1;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.hero-caption {
font-size: 0.76rem;
color: var(--text-muted);
}
.hero-facts {
flex: 1;
min-width: 0;
}
.hero-headline {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.9rem;
}
.hero-list {
display: flex;
gap: 2rem;
margin: 0;
flex-wrap: wrap;
}
.hero-list dt {
font-size: 0.74rem;
color: var(--text-muted);
margin-bottom: 0.2rem;
}
.hero-list dd {
margin: 0;
font-size: 1.05rem;
font-weight: 620;
color: var(--text-primary);
}
.hero-skeleton {
height: 164px;
border-radius: 14px;
background: var(--surface-1);
border: 1px solid var(--border);
margin-bottom: 1.75rem;
}
/* Charts ------------------------------------------------------------------ */
.charts-section {
margin-top: 2rem;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 1rem;
}
@media (max-width: 700px) {
.hero {
flex-direction: column;
text-align: center;
gap: 1.25rem;
padding: 1.5rem 1rem;
}
.hero-list {
justify-content: center;
gap: 1.5rem;
}
.charts-grid {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.hero { animation: none; }
}

View File

@@ -3,6 +3,10 @@ import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api'; import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/charts/Chart'; import Chart from '../components/charts/Chart';
import StatTile, { Status } from '../components/charts/StatTile'; import StatTile, { Status } from '../components/charts/StatTile';
import Ring from '../components/charts/Ring';
import Skeleton from '../components/Skeleton';
import { useCountUp } from '../lib/motion';
import './Dashboard.css';
import './Pages.css'; import './Pages.css';
const DAYS = 30; const DAYS = 30;
@@ -32,6 +36,59 @@ function rhrStatus(bpm: number | null): [Status, string] | [] {
return ['good', '正常']; return ['good', '正常'];
} }
/** The one hero figure on this view: today's steps against the day's goal. */
function StepHero({ today, history }: { today: HealthDay; history: HealthDay[] }) {
const goal = today.stepGoal ?? null;
const steps = today.steps ?? null;
const progress = steps != null && goal ? steps / goal : null;
const animated = useCountUp(steps);
const week = history.slice(-7).map((d) => d.steps).filter((v): v is number => v != null);
const weekAvg = week.length
? Math.round(week.reduce((a, b) => a + b, 0) / week.length)
: null;
const remaining = steps != null && goal ? goal - steps : null;
return (
<section className="hero">
<Ring
progress={progress}
label={`步数完成度 ${progress != null ? Math.round(progress * 100) : 0}%`}
>
<span className="hero-value">
{steps == null ? '—' : Math.round(animated ?? steps).toLocaleString()}
</span>
<span className="hero-caption"></span>
</Ring>
<div className="hero-facts">
<div className="hero-headline">
{progress == null
? '今日暂无步数记录'
: progress >= 1
? '今日目标已完成'
: `距目标还差 ${remaining!.toLocaleString()}`}
</div>
<dl className="hero-list">
<div>
<dt></dt>
<dd>{goal ? goal.toLocaleString() : '—'}</dd>
</div>
<div>
<dt> 7 </dt>
<dd>{weekAvg ? weekAvg.toLocaleString() : '—'}</dd>
</div>
<div>
<dt></dt>
<dd>{progress != null ? `${Math.round(progress * 100)}%` : '—'}</dd>
</div>
</dl>
</div>
</section>
);
}
function Dashboard() { function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -57,7 +114,15 @@ function Dashboard() {
load(); load();
}, []); }, []);
if (loading) return <div className="page-loading"></div>; if (loading) {
return (
<div className="page">
<h2></h2>
<div className="hero-skeleton" aria-hidden="true" />
<Skeleton count={6} />
</div>
);
}
if (error) { if (error) {
return ( return (
@@ -104,6 +169,8 @@ function Dashboard() {
<Link to="/trends" className="btn btn-plain"> </Link> <Link to="/trends" className="btn btn-plain"> </Link>
</header> </header>
<StepHero today={today} history={days} />
{/* Activity ---------------------------------------------------------- */} {/* Activity ---------------------------------------------------------- */}
<section className="section"> <section className="section">
<h3 className="section-title"></h3> <h3 className="section-title"></h3>
@@ -111,6 +178,7 @@ function Dashboard() {
<StatTile <StatTile
label="步数" label="步数"
value={today.steps} value={today.steps}
trend={days.map((d) => d.steps)}
detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined} detail={today.stepGoal ? `目标 ${today.stepGoal.toLocaleString()}` : undefined}
progress={ progress={
today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null today.steps != null && today.stepGoal ? today.steps / today.stepGoal : null
@@ -118,18 +186,22 @@ function Dashboard() {
/> />
<StatTile <StatTile
label="距离" label="距离"
value={round(today.distanceMeters != null ? today.distanceMeters / 1000 : null, 2)} trend={days.map((d) => d.distanceMeters)}
value={today.distanceMeters != null ? today.distanceMeters / 1000 : null}
unit="km" unit="km"
decimals={2}
/> />
<StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" /> <StatTile label="爬楼" value={round(today.floorsAscended)} unit="层" />
<StatTile <StatTile
label="强度分钟" label="强度分钟"
trend={days.map((d) => d.intensityMinutes)}
value={today.intensityMinutes} value={today.intensityMinutes}
unit="分钟" unit="分钟"
detail="中等以上强度" detail="中等以上强度"
/> />
<StatTile <StatTile
label="总消耗" label="总消耗"
trend={days.map((d) => d.caloriesBurned)}
value={round(today.caloriesBurned)} value={round(today.caloriesBurned)}
unit="kcal" unit="kcal"
detail={ detail={
@@ -140,10 +212,10 @@ function Dashboard() {
/> />
<StatTile <StatTile
label="久坐" label="久坐"
value={round( trend={days.map((d) => d.sedentarySeconds)}
today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null, 1 value={today.sedentarySeconds != null ? today.sedentarySeconds / 3600 : null}
)}
unit="小时" unit="小时"
decimals={1}
/> />
</div> </div>
</section> </section>
@@ -154,6 +226,7 @@ function Dashboard() {
<div className="tile-grid"> <div className="tile-grid">
<StatTile <StatTile
label="静息心率" label="静息心率"
trend={days.map((d) => d.heartRate)}
value={today.heartRate} value={today.heartRate}
unit="bpm" unit="bpm"
status={rhrTone} status={rhrTone}
@@ -171,12 +244,15 @@ function Dashboard() {
/> />
<StatTile <StatTile
label="心率变异性" label="心率变异性"
decimals={1}
trend={days.map((d) => d.heartRateVariability)}
value={round(today.heartRateVariability)} value={round(today.heartRateVariability)}
unit="ms" unit="ms"
detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined} detail={avgHrv != null ? `30 日均 ${Math.round(avgHrv)}` : undefined}
/> />
<StatTile <StatTile
label="平均压力" label="平均压力"
trend={days.map((d) => d.stress)}
value={today.stress} value={today.stress}
detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined} detail={today.stressMax != null ? `峰值 ${today.stressMax}` : undefined}
/> />
@@ -203,8 +279,10 @@ function Dashboard() {
<div className="tile-grid"> <div className="tile-grid">
<StatTile <StatTile
label="睡眠时长" label="睡眠时长"
trend={days.map((d) => d.sleepDuration)}
value={today.sleepDuration} value={today.sleepDuration}
unit="小时" unit="小时"
decimals={1}
status={sleepTone} status={sleepTone}
statusLabel={sleepWord} statusLabel={sleepWord}
detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined} detail={avgSleep != null ? `30 日均 ${avgSleep.toFixed(1)}` : undefined}
@@ -212,12 +290,16 @@ function Dashboard() {
<StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" /> <StatTile label="睡眠评分" value={round(today.sleepQuality)} unit="/100" />
<StatTile <StatTile
label="血氧" label="血氧"
decimals={1}
trend={days.map((d) => d.spo2Avg)}
value={round(today.spo2Avg)} value={round(today.spo2Avg)}
unit="%" unit="%"
detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined} detail={today.spo2Min != null ? `最低 ${today.spo2Min}%` : undefined}
/> />
<StatTile <StatTile
label="呼吸频率" label="呼吸频率"
decimals={1}
trend={days.map((d) => d.respirationAvg)}
value={round(today.respirationAvg)} value={round(today.respirationAvg)}
unit="次/分" unit="次/分"
detail={ detail={

View File

@@ -455,3 +455,79 @@
color: var(--text-muted); color: var(--text-muted);
min-width: 2.4em; min-width: 2.4em;
} }
/* Micro-interactions ------------------------------------------------------- */
.btn:active:not(:disabled),
.chip:active,
.range-tab:active,
.metric-tab:active {
transform: scale(0.97);
}
.btn,
.chip,
.range-tab,
.metric-tab {
transition: transform 0.12s var(--ease), background 0.15s var(--ease),
border-color 0.15s var(--ease), color 0.15s var(--ease);
}
.badge {
transition: transform 0.18s var(--ease), box-shadow 0.18s var(--ease),
border-color 0.18s var(--ease);
animation: tile-in 0.35s var(--ease) both;
}
.badge:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
border-color: var(--border-strong);
}
.badge-grid > *:nth-child(2) { animation-delay: 30ms; }
.badge-grid > *:nth-child(3) { animation-delay: 60ms; }
.badge-grid > *:nth-child(4) { animation-delay: 90ms; }
.badge-grid > *:nth-child(n + 5) { animation-delay: 110ms; }
.data-table tbody tr {
transition: background 0.12s var(--ease);
}
.data-table tbody tr:hover {
background: var(--surface-0);
}
.metric-row {
transition: background 0.12s var(--ease);
}
.metric-row:hover {
background: var(--surface-0);
}
.page {
animation: page-in 0.3s var(--ease) both;
}
@keyframes page-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* The viewer asked for less movement; skip it rather than merely shortening
it — sweeping motion is what causes the discomfort, not its duration. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
.btn:active:not(:disabled),
.chip:active,
.badge:hover {
transform: none;
}
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { apiClient, errorMessage, HealthDay } from '../services/api'; import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart from '../components/charts/Chart'; import Chart from '../components/charts/Chart';
import StatTile from '../components/charts/StatTile'; import StatTile from '../components/charts/StatTile';
import Skeleton from '../components/Skeleton';
import './Pages.css'; import './Pages.css';
const RANGES = [7, 14, 30, 90]; const RANGES = [7, 14, 30, 90];
@@ -95,7 +96,13 @@ function Sleep() {
</header> </header>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>} {loading && (
<>
<Skeleton count={6} />
<div style={{ height: '1rem' }} />
<Skeleton count={2} variant="chart" />
</>
)}
{!loading && !error && nights.length === 0 && ( {!loading && !error && nights.length === 0 && (
<div className="empty-state"> <div className="empty-state">

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { apiClient, errorMessage, HealthDay } from '../services/api'; import { apiClient, errorMessage, HealthDay } from '../services/api';
import Chart, { Series } from '../components/charts/Chart'; import Chart, { Series } from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import { import {
aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity, aggregate, Granularity, GRANULARITIES, isCumulative, suggestGranularity,
} from '../lib/aggregate'; } from '../lib/aggregate';
@@ -296,7 +297,7 @@ function Trends() {
</section> </section>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{loading && <div className="page-loading"></div>} {loading && <Skeleton count={6} variant="chart" />}
{!loading && !error && visible.length === 0 && ( {!loading && !error && visible.length === 0 && (
<p className="placeholder"></p> <p className="placeholder"></p>

View File

@@ -47,8 +47,16 @@
--accent-solid: #256abf; --accent-solid: #256abf;
--accent-soft: #eef4fd; --accent-soft: #eef4fd;
/* Sparkline ink is de-emphasised so the number it accompanies stays the
figure; the ring track is a lighter step of the fill's own ramp so the
pair reads as one meter. */
--spark-ink: #9ec5f4;
--ring-track: #dfe9f7;
--radius: 10px; --radius: 10px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03); --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03);
--shadow-lift: 0 2px 4px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.07);
--ease: cubic-bezier(0.22, 1, 0.36, 1);
} }
/* Dark steps are the same hues re-stepped for the dark surface — selected and /* Dark steps are the same hues re-stepped for the dark surface — selected and
@@ -57,14 +65,17 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])) { :root:where(:not([data-theme='light'])) {
color-scheme: dark; color-scheme: dark;
--surface-0: #131312; /* A cool near-black rather than a warm one: the blue-black surface is
--surface-1: #1a1a19; what reads as a fitness app, and the series steps were re-validated
--surface-2: #232322; against it (all four still clear 3:1 and every CVD gate). */
--border: #34342f; --surface-0: #0f1115;
--border-strong: #45443e; --surface-1: #181b21;
--text-primary: #ffffff; --surface-2: #1f232b;
--text-secondary: #c3c2b7; --border: #262a33;
--text-muted: #8f8e85; --border-strong: #363b47;
--text-primary: #e9ecf1;
--text-secondary: #a9b1bd;
--text-muted: #78818f;
--series-1: #3987e5; --series-1: #3987e5;
--series-2: #d95926; --series-2: #d95926;
@@ -73,25 +84,32 @@
--series-5: #d55181; --series-5: #d55181;
--series-6: #008300; --series-6: #008300;
--grid: #2c2c28; --grid: #22262e;
--accent: #3987e5; --accent: #3987e5;
--accent-solid: #1c5cab; --accent-solid: #1c5cab;
--accent-soft: #1d2938; --accent-soft: #17233a;
--spark-ink: #4a6f9e;
--ring-track: #2c3a4d;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.5);
} }
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--surface-0: #131312; /* A cool near-black rather than a warm one: the blue-black surface is
--surface-1: #1a1a19; what reads as a fitness app, and the series steps were re-validated
--surface-2: #232322; against it (all four still clear 3:1 and every CVD gate). */
--border: #34342f; --surface-0: #0f1115;
--border-strong: #45443e; --surface-1: #181b21;
--text-primary: #ffffff; --surface-2: #1f232b;
--text-secondary: #c3c2b7; --border: #262a33;
--text-muted: #8f8e85; --border-strong: #363b47;
--text-primary: #e9ecf1;
--text-secondary: #a9b1bd;
--text-muted: #78818f;
--series-1: #3987e5; --series-1: #3987e5;
--series-2: #d95926; --series-2: #d95926;
@@ -100,10 +118,14 @@
--series-5: #d55181; --series-5: #d55181;
--series-6: #008300; --series-6: #008300;
--grid: #2c2c28; --grid: #22262e;
--accent: #3987e5; --accent: #3987e5;
--accent-solid: #1c5cab; --accent-solid: #1c5cab;
--accent-soft: #1d2938; --accent-soft: #17233a;
--spark-ink: #4a6f9e;
--ring-track: #2c3a4d;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.5);
} }