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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user