import { useEffect, useState } from 'react'; import { Link, f7 } from 'framework7-react'; import { apiClient, errorMessage, HealthDay } from '../services/api'; import Screen from '../components/Screen'; import Ring from '../components/charts/Ring'; import MetricCard from '../components/charts/MetricCard'; import MetricStrip from '../components/charts/MetricStrip'; import Skeleton from '../components/Skeleton'; import { useCountUp } from '../lib/motion'; import { RANGES } from '../lib/ranges'; import { METRICS, metricHref } from '../lib/metrics'; import { daysAgo, iso, shiftDay, today as todayIso } from '../lib/day'; import './Today.css'; /* A year is loaded up front so stepping back a day costs no request. */ const HISTORY_DAYS = 365; interface RingSpec { label: string; value: number | null; /** Reaching this counts as a full ring. */ target: number | null; unit: string; decimals?: number; goalText: string; } /** * Three rings: steps, sleep and HRV. * * Each is normalised against the point where it enters its own reference band, * so a full ring means the same thing for all three even though the units do * not compare. Rings sit side by side rather than concentric — Apple's nested * form works because its three rings share one idea (move/exercise/stand); * these three are unrelated measures and read more clearly apart. */ function RingRow({ today, history }: { today: HealthDay; history: HealthDay[] }) { const stepGoal = today.stepGoal ?? RANGES.steps.goodFrom; const specs: RingSpec[] = [ { label: '步数', value: today.steps, target: stepGoal, unit: '步', goalText: `目标 ${stepGoal.toLocaleString()}`, }, { label: '睡眠', value: today.sleepDuration, target: RANGES.sleepDuration.goodFrom, unit: '小时', decimals: 1, goalText: `目标 ${RANGES.sleepDuration.goodFrom} 小时`, }, { label: 'HRV', value: today.heartRateVariability, target: RANGES.heartRateVariability.goodFrom, unit: 'ms', goalText: `参考 ≥${RANGES.heartRateVariability.goodFrom} ms`, }, ]; 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 done = specs.filter((s) => s.value != null && s.target && s.value >= s.target).length; return (
{specs.map((s) => ( ))}
{done === 3 ? '三项全部达标' : done === 0 ? '今日尚无达标项' : `${done} / 3 项达标`} {weekAvg != null && ( 步数近 7 日均 {weekAvg.toLocaleString()} )}
); } function RingCell({ spec }: { spec: RingSpec }) { const animated = useCountUp(spec.value); const progress = spec.value != null && spec.target ? spec.value / spec.target : null; const shown = spec.value == null ? '—' : (animated ?? spec.value).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: spec.decimals ?? 0, }); return (
{shown} {spec.unit}
{spec.label}
{spec.goalText}
); } /* Which cards each section shows, by registry id. */ const SECTIONS: Array<{ title: string; items: string[] }> = [ { title: '活动', items: ['steps', 'intensityMinutes', 'floorsAscended', 'distance'] }, { title: '心率与压力', items: ['heartRate', 'heartRateVariability', 'stress', 'trainingReadiness'] }, { title: '睡眠', items: ['sleepDuration', 'sleepQuality'] }, ]; function TodayPage() { const [days, setDays] = useState([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); useEffect(() => { const load = async () => { try { // A year in one read: browsing back a day should not cost a request, // and the cards' sparklines need the surrounding days anyway. setDays(await apiClient.getHealthSummary( daysAgo(HISTORY_DAYS - 1), todayIso() )); } catch (err: any) { setError(errorMessage(err, '加载数据失败')); } finally { setLoading(false); } }; load(); }, []); const newest = days.length ? days[days.length - 1].date : null; const date = selected ?? newest; const index = date ? days.findIndex((d) => d.date === date) : -1; const today = index >= 0 ? days[index] : undefined; // The window feeding the sparklines ends at the day being viewed, so the // trend on a card always leads up to the number above it. const history = index >= 0 ? days.slice(Math.max(0, index - 13), index + 1) : []; const oldest = days.length ? days[0].date : null; const canPrev = !!(date && oldest && date > oldest); const canNext = !!(date && newest && date < newest); const go = (delta: number) => { if (!date) return; const target = shiftDay(date, delta); // Days with no record at all are skipped over rather than shown blank. const nearest = delta < 0 ? [...days].reverse().find((d) => d.date <= target) : days.find((d) => d.date >= target); if (nearest) setSelected(nearest.date); }; const openCalendar = () => { if (!date) return; const calendar = f7.calendar.create({ value: [new Date(`${date}T12:00:00`)], minDate: oldest ? new Date(`${oldest}T12:00:00`) : undefined, maxDate: newest ? new Date(`${newest}T12:00:00`) : undefined, closeOnSelect: true, on: { change(_c: any, value: unknown) { const values = value as Date[]; if (!values?.length) return; const picked = iso(values[0]); const match = days.find((d) => d.date === picked); setSelected(match ? match.date : picked); }, closed(c: any) { c.destroy(); }, }, }); calendar.open(); }; const isToday = date === todayIso(); const weekday = date ? ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][ new Date(`${date}T12:00:00`).getDay() ] : ''; const open = (id: string) => f7.views.current.router.navigate(metricHref(id)); return ( {loading && ( <>