Files
GarminHealthLab/client/src/pages/ActivityDetailPage.tsx
ericwyuan 241ae0d6a3 feat(ai): 每个数据页面都有 AI 解读,靠一条带优先级的生产者/消费者队列
原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外
的 10 个页面都有:健康、睡眠、运动、趋势、每日、身体成分、成绩预测、
身体年龄、挑战赛、运动详情。

不是给每个页面写一套,而是一个通用管线:
- services/scopes.py:一个页面一个 context builder,返回同一个信封。
  context["highlights"] 是已经算好的白话事实——模型负责解读它们,模型不
  可用时规则引擎原样渲染。两者引用同一批数字,所以降级读起来不像换了个 App。
  没数据的页面返回 None,宁可不出卡片,也不让模型对着空表格发挥。
- coach.scope_messages / parse_scope_insight:一套提示词吃所有页面,页面
  的差异全在 context 里,加页面 = 加一个 builder。
- 前端 <AiPanel scope="…">:一个组件渲染所有页面,轮询逻辑抽成
  lib/insight.ts 的 usePolledInsight,晨报卡也改用它。

## 队列

一次生成 40 秒到 4.5 分钟,所以什么都不能在请求里生成。页面只负责入队,
worker 负责消费(services/jobs.py)。

优先级才是用队列而不是后台线程的理由:同步完成后 prefetch 把所有页面按
背景优先级排进去,可能要跑半小时;而用户一打开某个页面,那个页面的任务
立刻提到队首、下一个就跑。你在看什么,队列就在算什么。

队列放在数据库而不是内存里,因为 gunicorn 有两个 worker:任务带 holder
声明后回读确认,和 scheduler.py 抢 tick 是同一套做法。id 由
user+kind+subject 推导,所以每几秒一次的轮询是幂等的入队,不会每几秒堆一
个任务。

## 网关中断时踩到的两个坑(当场修了)

写完正好赶上 oracle 那台机器不通,于是看到:
- 三次失败后任务被永久标 failed,网关恢复了也不会重试——一次瞬时中断就把
  那个页面的解读判了死刑,直到它的数据碰巧变化。加了冷却期,过期后重置
  尝试次数再排一次。
- 队列已经放弃了,页面还在 pending 转圈,要转满 8 分钟才停。meta.pending
  现在跟着队列状态走,并把失败原因带给卡片。

顺带把 BAND_SOURCES 从 routes/settings.py 下沉到 services/insights.py:
教练要拿它做参照,而 services 不该反向依赖 routes。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:17:17 +08:00

467 lines
17 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { Link } from 'framework7-react';
import { apiClient, ActivityDetail, errorMessage } from '../services/api';
import Screen from '../components/Screen';
import AiPanel from '../components/AiPanel';
import { FEATURES } from '../features';
import Chart from '../components/charts/Chart';
import Skeleton from '../components/Skeleton';
import './ActivityDetail.css';
type Tab = 'overview' | 'stats' | 'laps' | 'charts';
type Axis = 'time' | 'distance';
const TABS: Array<[Tab, string]> = [
['overview', '概览'], ['stats', '数据'], ['laps', '分段'], ['charts', '图表'],
];
const TYPE_LABEL: Record<string, string> = {
running: '跑步', cycling: '骑行', walking: '步行', hiking: '徒步',
swimming: '游泳', table_tennis: '乒乓球', strength_training: '力量训练',
indoor_cycling: '室内骑行', treadmill_running: '跑步机', badminton: '羽毛球',
yoga: '瑜伽', mountaineering: '登山', elliptical: '椭圆机', rowing: '划船',
open_water_swimming: '公开水域游泳', stair_climbing: '爬楼梯',
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) => {
if (seconds == null) return '—';
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
};
/** Pace from speed in m/s, as mm:ss per km — the reading runners actually use. */
const pace = (metresPerSecond?: number | null) => {
if (!metresPerSecond) return '—';
const secondsPerKm = 1000 / metresPerSecond;
const m = Math.floor(secondsPerKm / 60);
const s = Math.round(secondsPerKm % 60);
return `${m}:${String(s).padStart(2, '0')} /km`;
};
const kmh = (metresPerSecond?: number | null) =>
metresPerSecond == null ? '—' : `${(metresPerSecond * 3.6).toFixed(1)} km/h`;
const num = (v?: number | null, digits = 0, unit = '') =>
v == null ? '—' : `${v.toLocaleString(undefined, {
minimumFractionDigits: digits, maximumFractionDigits: digits,
})}${unit ? ` ${unit}` : ''}`;
const km = (metres?: number | null) =>
metres == null ? '—' : `${(metres / 1000).toFixed(2)} km`;
/* Groups mirror the watch app's Stats tab, so a value sits where it is
expected. A group with nothing recorded is dropped rather than shown empty. */
function statGroups(s: Record<string, any>) {
return [
['配速', [
['平均配速', pace(s.averageSpeed)],
['移动平均配速', pace(s.averageMovingSpeed)],
['最快配速', pace(s.maxSpeed)],
]],
['速度', [
['平均速度', kmh(s.averageSpeed)],
['移动平均速度', kmh(s.averageMovingSpeed)],
['最高速度', kmh(s.maxSpeed)],
]],
['计时', [
['总时间', hms(s.duration)],
['移动时间', hms(s.movingDuration)],
['流逝时间', hms(s.elapsedDuration)],
]],
['心率', [
['平均心率', num(s.averageHR, 0, 'bpm')],
['最高心率', num(s.maxHR, 0, 'bpm')],
]],
['训练效果', [
['主要收益', BENEFIT[s.trainingEffectLabel] ?? '—'],
['有氧', num(s.trainingEffect, 1)],
['无氧', num(s.anaerobicTrainingEffect, 1)],
['运动负荷', num(s.activityTrainingLoad, 0)],
]],
['营养与补水', [
['静息消耗', num(s.bmrCalories, 0, 'kcal')],
['活动消耗', num(
s.calories != null && s.bmrCalories != null
? s.calories - s.bmrCalories : null, 0, 'kcal')],
['总消耗', num(s.calories, 0, 'kcal')],
['预估出汗量', num(s.waterEstimated, 0, 'ml')],
]],
['温度', [
['平均温度', num(s.averageTemperature, 0, '°C')],
['最低温度', num(s.minTemperature, 0, '°C')],
['最高温度', num(s.maxTemperature, 0, '°C')],
]],
['强度分钟', [
['中等', num(s.moderateIntensityMinutes, 0, '分钟')],
['高强度', num(s.vigorousIntensityMinutes, 0, '分钟')],
['合计', num(
(s.moderateIntensityMinutes ?? 0) + (s.vigorousIntensityMinutes ?? 0) || null,
0, '分钟')],
]],
['海拔', [
['总爬升', num(s.elevationGain, 0, 'm')],
['总下降', num(s.elevationLoss, 0, 'm')],
['最低海拔', num(s.minElevation, 0, 'm')],
['最高海拔', num(s.maxElevation, 0, 'm')],
]],
['步频', [
['平均步频', num(s.averageRunCadence ?? s.averageBikeCadence, 0, 'spm')],
['最高步频', num(s.maxRunCadence ?? s.maxBikeCadence, 0, 'spm')],
]],
['功率', [
['平均功率', num(s.averagePower, 0, 'W')],
['最大功率', num(s.maxPower, 0, 'W')],
['标准化功率', num(s.normPower, 0, 'W')],
]],
] as Array<[string, Array<[string, string]>]>;
}
/* --- heart-rate zones ---------------------------------------------------- */
const ZONE_NAME = ['', '热身', '轻松', '有氧', '阈值', '最大'];
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">
<h3 className="sec-title"></h3>
<div className="zones">
{[...zones].reverse().map((z) => {
const share = total ? (z.seconds / total) * 100 : 0;
return (
<div className="zone" key={z.zone}>
<div className="zone-head">
<span className="zone-name">
{z.zone}
<span className="zone-range">
{z.lowBoundary != null ? `${Math.round(z.lowBoundary)} bpm` : ''}
{ZONE_NAME[z.zone] ? ` · ${ZONE_NAME[z.zone]}` : ''}
</span>
</span>
<span className="zone-time">
{hms(z.seconds)}<span className="zone-pct">{Math.round(share)}%</span>
</span>
</div>
<div className="zone-bar">
<div
className={`zone-fill z${z.zone}`}
style={{ width: `${share}%` }}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
/* --- page ---------------------------------------------------------------- */
interface Props {
id?: string;
f7route?: { params: { id: string } };
}
function ActivityDetailPage({ id, f7route }: Props) {
const activityId = id ?? f7route?.params.id ?? '';
const [detail, setDetail] = useState<ActivityDetail | null>(null);
const [tab, setTab] = useState<Tab>('overview');
const [axis, setAxis] = useState<Axis>('time');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [needsSync, setNeedsSync] = useState(false);
useEffect(() => {
let cancelled = false;
apiClient
.getActivityDetail(activityId)
.then((d) => { if (!cancelled) setDetail(d); })
.catch((err) => {
if (cancelled) return;
// Not yet synced is an ordinary state with an obvious next step, not
// a failure to apologise for.
if (err?.response?.status === 404 && err.response.data?.needsSync) {
setNeedsSync(true);
} else {
setError(errorMessage(err, '加载失败'));
}
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [activityId]);
/* The series arrive as parallel arrays; recharts wants one row per sample.
The x value is whichever axis is selected, formatted for reading. */
const chartRows = useMemo(() => {
if (!detail) return [];
const s = detail.series;
const length = Math.max(...Object.values(s).map((a) => a.length), 0);
if (!length) return [];
const elapsed = s.elapsed ?? s.duration;
return Array.from({ length }, (_, i) => ({
x: axis === 'distance'
? s.distance?.[i] != null ? +(s.distance[i]! / 1000).toFixed(2) : null
: elapsed?.[i] != null ? Math.round(elapsed[i]! / 60) : i,
heartRate: s.heartRate?.[i] ?? null,
speed: s.speed?.[i] != null ? +(s.speed[i]! * 3.6).toFixed(1) : null,
elevation: s.elevation?.[i] ?? null,
cadence: s.cadence?.[i] ?? null,
temperature: s.temperature?.[i] ?? null,
power: s.power?.[i] ?? null,
})).filter((row) => row.x != null);
}, [detail, axis]);
const title = detail
? detail.activityName
|| TYPE_LABEL[detail.activityType ?? '']
|| detail.activityType
|| '运动'
: '运动';
if (loading) {
return (
<Screen title="运动详情" backLink>
<Skeleton count={4} />
</Screen>
);
}
if (needsSync) {
return (
<Screen title="运动详情" backLink>
<div className="screen-empty">
<p></p>
<Link href="/sync/" className="button button-fill button-round">
</Link>
</div>
</Screen>
);
}
if (error || !detail) {
return (
<Screen title="运动详情" backLink>
<div className="screen-error">{error || '加载失败'}</div>
</Screen>
);
}
const s = detail.summary as Record<string, any>;
const hasDistance = !!detail.series.distance?.some((v) => v != null);
const charts: Array<[string, string, string, 'area' | 'line', number]> = [
['heartRate', '心率', 'bpm', 'area', 1],
['speed', '速度', 'km/h', 'area', 2],
['elevation', '海拔', 'm', 'area', 3],
['cadence', '步频', 'spm', 'line', 4],
['power', '功率', 'W', 'line', 5],
['temperature', '温度', '°C', 'line', 6],
];
return (
<Screen title={title} subtitle={s.startTimeLocal?.slice(0, 16).replace('T', ' ')} backLink>
{FEATURES.ai && <AiPanel scope="activity" subject={activityId} title="AI 本次运动解读" />}
<div className="metric-tabs">
{TABS.map(([tabId, text]) => (
<button
key={tabId}
className={`metric-tab ${tab === tabId ? 'active' : ''}`}
onClick={() => setTab(tabId)}
>
{text}
</button>
))}
</div>
{tab === 'overview' && (
<>
<section className="ad-hero">
<div className="ad-hero-main">
<span className="ad-hero-value">
{s.distance ? (s.distance / 1000).toFixed(2) : hms(s.duration)}
</span>
<span className="ad-hero-unit">{s.distance ? 'km' : ''}</span>
</div>
<div className="ad-hero-label">{s.distance ? '距离' : '总时间'}</div>
</section>
<div className="ad-grid">
{[
['总时间', hms(s.duration)],
['平均心率', num(s.averageHR, 0, 'bpm')],
['平均速度', kmh(s.averageSpeed)],
['总消耗', num(s.calories, 0, 'kcal')],
['总爬升', num(s.elevationGain, 0, 'm')],
['有氧效果', num(s.trainingEffect, 1)],
].map(([label, value]) => (
<div className="ad-tile" key={label}>
<span className="ad-tile-value">{value}</span>
<span className="ad-tile-label">{label}</span>
</div>
))}
</div>
{BENEFIT[s.trainingEffectLabel] && (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ad-eval">
<div className="ad-eval-name">{BENEFIT[s.trainingEffectLabel]}</div>
<div className="ad-eval-note"></div>
</div>
</section>
)}
<Zones zones={detail.hrZones} duration={s.duration} />
{!!detail.gear.length && (
<section className="sec">
<h3 className="sec-title"></h3>
<div className="ad-gear">
{detail.gear.map((g, i) => (
<div className="ad-gear-item" key={g.uuid ?? i}>
{g.displayName ?? g.customMakeModel ?? '装备'}
</div>
))}
</div>
</section>
)}
</>
)}
{tab === 'stats' && (
<div className="ad-stats">
{statGroups(s)
.filter(([, rows]) => rows.some(([, v]) => v !== '—'))
.map(([group, rows]) => (
<section className="sec" key={group}>
<h3 className="sec-title">{group}</h3>
<div className="ad-rows">
{rows.filter(([, v]) => v !== '—').map(([label, value]) => (
<div className="ad-row" key={label}>
<span className="ad-row-label">{label}</span>
<span className="ad-row-value">{value}</span>
</div>
))}
</div>
</section>
))}
</div>
)}
{tab === 'laps' && (
detail.laps.length === 0 ? (
<p className="screen-note"></p>
) : (
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{detail.laps.map((lap) => (
<tr key={lap.index}>
<th scope="row">{lap.index}</th>
<td>{hms(lap.duration)}</td>
<td className="num">{km(lap.distance)}</td>
<td className="num">{kmh(lap.averageSpeed)}</td>
<td className="num">{num(lap.averageHR, 0)}</td>
</tr>
))}
<tr className="ad-total">
<th scope="row"></th>
<td>{hms(s.duration)}</td>
<td className="num">{km(s.distance)}</td>
<td className="num">{kmh(s.averageSpeed)}</td>
<td className="num">{num(s.averageHR, 0)}</td>
</tr>
</tbody>
</table>
</div>
)
)}
{tab === 'charts' && (
chartRows.length === 0 ? (
<p className="screen-note">线</p>
) : (
<>
{hasDistance && (
<div className="segmented-row">
<span className="segmented-label"></span>
<div className="segmented">
<button className={axis === 'time' ? 'on' : ''} onClick={() => setAxis('time')}>
</button>
<button className={axis === 'distance' ? 'on' : ''} onClick={() => setAxis('distance')}>
</button>
</div>
</div>
)}
<div className="chart-grid">
{charts
.filter(([key]) => chartRows.some((r) => (r as any)[key] != null))
.map(([key, label, unit, type, slot]) => (
<Chart
key={key}
title={label}
unit={unit}
data={chartRows}
xKey="x"
type={type}
height={190}
series={[{
key, label, slot: slot as 1 | 2 | 3 | 4 | 5 | 6,
unit, decimals: key === 'speed' ? 1 : 0,
}]}
footer={axis === 'time' ? '横轴:分钟' : '横轴:公里'}
/>
))}
</div>
</>
)
)}
</Screen>
);
}
export default ActivityDetailPage;