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 = { 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 = { 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) { 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 (

心率区间

{[...zones].reverse().map((z) => { const share = total ? (z.seconds / total) * 100 : 0; return (
区间 {z.zone} {z.lowBoundary != null ? `≥ ${Math.round(z.lowBoundary)} bpm` : ''} {ZONE_NAME[z.zone] ? ` · ${ZONE_NAME[z.zone]}` : ''} {hms(z.seconds)}{Math.round(share)}%
); })}
); } /* --- page ---------------------------------------------------------------- */ interface Props { id?: string; f7route?: { params: { id: string } }; } function ActivityDetailPage({ id, f7route }: Props) { const activityId = id ?? f7route?.params.id ?? ''; const [detail, setDetail] = useState(null); const [tab, setTab] = useState('overview'); const [axis, setAxis] = useState('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 ( ); } if (needsSync) { return (

这条运动的详细数据还没同步到本机。

去同步
); } if (error || !detail) { return (
{error || '加载失败'}
); } const s = detail.summary as Record; 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 ( {FEATURES.ai && }
{TABS.map(([tabId, text]) => ( ))}
{tab === 'overview' && ( <>
{s.distance ? (s.distance / 1000).toFixed(2) : hms(s.duration)} {s.distance ? 'km' : ''}
{s.distance ? '距离' : '总时间'}
{[ ['总时间', 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]) => (
{value} {label}
))}
{BENEFIT[s.trainingEffectLabel] && (

评估

{BENEFIT[s.trainingEffectLabel]}
主要收益
)} {!!detail.gear.length && (

装备

{detail.gear.map((g, i) => (
{g.displayName ?? g.customMakeModel ?? '装备'}
))}
)} )} {tab === 'stats' && (
{statGroups(s) .filter(([, rows]) => rows.some(([, v]) => v !== '—')) .map(([group, rows]) => (

{group}

{rows.filter(([, v]) => v !== '—').map(([label, value]) => (
{label} {value}
))}
))}
)} {tab === 'laps' && ( detail.laps.length === 0 ? (

这次运动没有分段记录。

) : (
{detail.laps.map((lap) => ( ))}
分段 时间 距离 平均速度 平均心率
{lap.index} {hms(lap.duration)} {km(lap.distance)} {kmh(lap.averageSpeed)} {num(lap.averageHR, 0)}
合计 {hms(s.duration)} {km(s.distance)} {kmh(s.averageSpeed)} {num(s.averageHR, 0)}
) )} {tab === 'charts' && ( chartRows.length === 0 ? (

这次运动没有采样曲线。

) : ( <> {hasDistance && (
横轴
)}
{charts .filter(([key]) => chartRows.some((r) => (r as any)[key] != null)) .map(([key, label, unit, type, slot]) => ( ))}
) )}
); } export default ActivityDetailPage;