原来只有今日页有晨报、指标详情页有归因,其余页面一片空白。现在除设置外 的 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>
467 lines
17 KiB
TypeScript
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;
|