Files
GarminHealthLab/client/src/pages/TodayPage.tsx
ericwyuan fa865ca8a6 fix: 实机验证发现的七个缺陷
部署与时区
- client/.env.production 写死 REACT_APP_API_URL=/api。之前没有这个文件,
  构建靠命令行临时传参,一旦忘了就把开发默认值 localhost:5000 打进包里,
  部署后整站 Network Error。
- 新增 lib/day.ts,所有日期改用本地日历日。原先用 toISOString() 取的是 UTC 日期,
  在 UTC+8 每天前 8 小时都会少查一天——当天的数据佳明已经有了,应用却够不到。

界面
- 覆盖 Framework7 9 给 .navbar .left/.right 加的 frosted pill,
  就是各页右上角和返回键旁边那个半透明椭圆。
- .metric-tab 显式 width:auto。F7 把每个 button 渲染成整宽块元素,
  运动详情的四个 Tab 因此竖着堆成四行。
- 主要收益为 UNKNOWN 时不显示该区块,那是「没有结论」的哨兵值。

正确性
- 心率区间百分比改用整次运动时长作分母。原先除以「落在区间内的总时长」,
  把低于区间 1 的时间挤掉了:44:06 的登山里区间 1 占 23:03,
  手表显示 52%,我算成了 90%。现在对上了。

性能
- 按进程缓存已认证的 Garmin 会话(15 分钟 TTL)。实测 _connect 单次 11 秒,
  而七个数据接口加起来才 4 秒——瓶颈全在每次重新认证。
  冷启 16s → 热 7s → 命中缓存 0.8s,不再撞客户端超时。
- get_activity_details 的 maxchart 由 2000 降到 500,反正写入时抽稀到 300。
- 重新绑定账号时丢弃缓存会话。

删除前端重做前遗留的 5 个无引用页面文件。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 01:16:44 +08:00

310 lines
10 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, 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 (
<section className="hero">
<div className="ring-row">
{specs.map((s) => (
<RingCell key={s.label} spec={s} />
))}
</div>
<div className="hero-summary">
<span className="hero-headline">
{done === 3 ? '三项全部达标' : done === 0 ? '今日尚无达标项' : `${done} / 3 项达标`}
</span>
{weekAvg != null && (
<span className="hero-aside"> 7 {weekAvg.toLocaleString()}</span>
)}
</div>
</section>
);
}
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 (
<div className="ring-cell">
<Ring
progress={progress}
size={96}
thickness={8}
label={`${spec.label} ${progress != null ? Math.round(progress * 100) : 0}%`}
>
<span className="ring-value">{shown}</span>
<span className="ring-unit">{spec.unit}</span>
</Ring>
<div className="ring-label">{spec.label}</div>
<div className="ring-goal">{spec.goalText}</div>
</div>
);
}
/* 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<HealthDay[]>([]);
const [selected, setSelected] = useState<string | null>(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 (
<Screen title={!date || isToday ? '今日' : '每日数据'} subtitle={date ?? undefined}>
{loading && (
<>
<div className="hero-skeleton" aria-hidden="true" />
<Skeleton count={4} />
</>
)}
{!loading && error && <div className="screen-error">{error}</div>}
{!loading && !error && !days.length && (
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round">
Garmin
</Link>
</div>
)}
{!loading && !error && !!days.length && date && (
<>
<div className="date-nav">
<button
className="date-arrow"
onClick={() => go(-1)}
disabled={!canPrev}
aria-label="前一天"
>
</button>
<button className="date-current" onClick={openCalendar}>
<span className="date-main">
{isToday ? '今天' : date.slice(5).replace('-', ' / ')}
</span>
<span className="date-sub">
{weekday}
<span className="date-cal" aria-hidden="true"></span>
</span>
</button>
<button
className="date-arrow"
onClick={() => go(1)}
disabled={!canNext}
aria-label="后一天"
>
</button>
</div>
{!today ? (
<p className="screen-note"></p>
) : (
<>
<RingRow today={today} history={history} />
{SECTIONS.map((section) => (
<section className="sec" key={section.title}>
<h3 className="sec-title">{section.title}</h3>
<div className="mcard-grid">
{section.items.map((id) => {
const def = METRICS[id];
if (!def) return null;
return (
<MetricCard
key={id}
metric={def.range}
label={def.label}
value={def.pick(today)}
unit={def.unit}
decimals={def.decimals}
trend={history.map(def.pick)}
detail={
id === 'distance' && today.caloriesBurned != null
? `消耗 ${Math.round(today.caloriesBurned).toLocaleString()} kcal`
: undefined
}
onClick={() => open(id)}
/>
);
})}
</div>
</section>
))}
<section className="sec">
<MetricStrip title="身体指标" items={[
{ metric: 'heartRateVariability', icon: '💓', label: 'HRV', value: today.heartRateVariability, unit: 'ms' },
{ metric: 'heartRate', icon: '❤️', label: '静息心率', value: today.heartRate, unit: 'bpm' },
{ metric: 'respirationAvg', icon: '🫁', label: '呼吸', value: today.respirationAvg, unit: '次/分', decimals: 1 },
{ metric: 'spo2Avg', icon: '🩸', label: '血氧', value: today.spo2Avg, unit: '%' },
{ metric: 'bodyBatteryHigh', icon: '🔋', label: '身体电量', value: today.bodyBatteryHigh, unit: '峰值' },
]} />
</section>
</>
)}
</>
)}
</Screen>
);
}
export default TodayPage;