部署与时区 - 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>
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
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;
|