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>
This commit is contained in:
ericwyuan
2026-08-24 01:16:44 +08:00
parent 8430ff335e
commit fa865ca8a6
22 changed files with 173 additions and 1416 deletions

View File

@@ -21,6 +21,16 @@ const TYPE_LABEL: Record<string, string> = {
fitness_equipment: '健身器械',
};
/* Garmin's primary-benefit labels. UNKNOWN is its sentinel for "no verdict"
— usually a session too short or too easy to classify — and must not be
printed as if it were one. */
const BENEFIT: Record<string, string | undefined> = {
RECOVERY: '恢复', BASE: '基础耐力', TEMPO: '节奏', THRESHOLD: '乳酸阈值',
VO2MAX: '最大摄氧量', ANAEROBIC_CAPACITY: '无氧能力', SPRINT: '冲刺',
AEROBIC_BASE: '有氧基础', LACTATE_THRESHOLD: '乳酸阈值',
UNKNOWN: undefined, NO_BENEFIT: undefined,
};
/* --- formatting ---------------------------------------------------------- */
const hms = (seconds?: number | null) => {
@@ -77,7 +87,7 @@ function statGroups(s: Record<string, any>) {
['最高心率', num(s.maxHR, 0, 'bpm')],
]],
['训练效果', [
['主要收益', s.trainingEffectLabel ?? '—'],
['主要收益', BENEFIT[s.trainingEffectLabel] ?? '—'],
['有氧', num(s.trainingEffect, 1)],
['无氧', num(s.anaerobicTrainingEffect, 1)],
['运动负荷', num(s.activityTrainingLoad, 0)],
@@ -124,9 +134,18 @@ function statGroups(s: Record<string, any>) {
const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大'];
function Zones({ zones }: { zones: ActivityDetail['hrZones'] }) {
const total = zones.reduce((sum, z) => sum + (z.seconds || 0), 0);
if (!total) return null;
function Zones({ zones, duration }: {
zones: ActivityDetail['hrZones'];
duration?: number | null;
}) {
const inZones = zones.reduce((sum, z) => sum + (z.seconds || 0), 0);
if (!inZones) return null;
/* The share is of the whole activity, not of the time that landed in a zone.
Dividing by the zone sum drops the minutes spent below zone 1 and inflates
everything: a walk with 23:03 in zone 1 out of 44:06 is 52%, which is what
the watch shows, not the 90% that the zone-sum denominator produces. */
const total = duration && duration >= inZones ? duration : inZones;
return (
<section className="sec">
@@ -287,17 +306,17 @@ function ActivityDetailPage({ id, f7route }: Props) {
))}
</div>
{s.trainingEffectLabel && (
{BENEFIT[s.trainingEffectLabel] && (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ad-eval">
<div className="ad-eval-name">{s.trainingEffectLabel}</div>
<div className="ad-eval-name">{BENEFIT[s.trainingEffectLabel]}</div>
<div className="ad-eval-note"></div>
</div>
</section>
)}
<Zones zones={detail.hrZones} />
<Zones zones={detail.hrZones} duration={s.duration} />
{!!detail.gear.length && (
<section className="sec">