import dayjs from 'dayjs'; import { fetchHeartRateSamplesForRange, HeartRateSample, WorkoutData, } from '@/utils/health'; export interface WorkoutHeartRatePoint { timestamp: string; value: number; } export interface HeartRateZoneStat { key: string; label: string; color: string; rangeText: string; durationMinutes: number; percentage: number; } export interface WorkoutDetailMetrics { durationLabel: string; durationSeconds: number; calories: number | null; mets: number | null; averageHeartRate: number | null; maximumHeartRate: number | null; minimumHeartRate: number | null; heartRateSeries: WorkoutHeartRatePoint[]; heartRateZones: HeartRateZoneStat[]; } const DEFAULT_SAMPLE_GAP_SECONDS = 5; const HEART_RATE_ZONES = [ { key: 'warmup', label: '热身放松', color: '#9FC7FF', rangeText: '<100次/分', min: 0, max: 100, }, { key: 'fatburn', label: '燃烧脂肪', color: '#5ED7B0', rangeText: '100-119次/分', min: 100, max: 120, }, { key: 'aerobic', label: '有氧运动', color: '#FFB74D', rangeText: '120-149次/分', min: 120, max: 150, }, { key: 'anaerobic', label: '无氧运动', color: '#FF826E', rangeText: '150-169次/分', min: 150, max: 170, }, { key: 'max', label: '身体极限', color: '#F2A4D8', rangeText: '≥170次/分', min: 170, max: Infinity, }, ]; export async function getWorkoutDetailMetrics( workout: WorkoutData ): Promise { const start = dayjs(workout.startDate || workout.endDate); const end = dayjs(workout.endDate || workout.startDate); const safeStart = start.isValid() ? start : dayjs(); const safeEnd = end.isValid() ? end : safeStart.add(workout.duration || 0, 'second'); const heartRateSamples = await fetchHeartRateSamplesForRange( safeStart.toDate(), safeEnd.toDate() ); const heartRateSeries = normalizeHeartRateSeries(heartRateSamples); const { average: averageHeartRate, max: maximumHeartRate, min: minimumHeartRate, } = calculateHeartRateStats(heartRateSeries, workout); const heartRateZones = calculateHeartRateZones(heartRateSeries); const durationSeconds = Math.max(Math.round(workout.duration || 0), 0); const durationLabel = formatDuration(durationSeconds); const calories = workout.totalEnergyBurned != null ? Math.round(workout.totalEnergyBurned) : null; const mets = extractMetsFromMetadata(workout.metadata) || calculateMetsFromWorkoutData(workout); return { durationLabel, durationSeconds, calories, mets, averageHeartRate, maximumHeartRate, minimumHeartRate, heartRateSeries, heartRateZones, }; } function formatDuration(durationSeconds: number): string { const hours = Math.floor(durationSeconds / 3600); const minutes = Math.floor((durationSeconds % 3600) / 60); const seconds = durationSeconds % 60; const parts = [hours, minutes, seconds].map((unit) => unit.toString().padStart(2, '0') ); return parts.join(':'); } function normalizeHeartRateSeries(samples: HeartRateSample[]): WorkoutHeartRatePoint[] { return samples .map((sample) => ({ timestamp: sample.endDate || sample.startDate, value: Number(sample.value), })) .filter((point) => dayjs(point.timestamp).isValid() && Number.isFinite(point.value)) .sort((a, b) => dayjs(a.timestamp).valueOf() - dayjs(b.timestamp).valueOf()); } function calculateHeartRateStats( series: WorkoutHeartRatePoint[], workout: WorkoutData ) { if (!series.length) { const fallback = workout.averageHeartRate ?? null; return { average: fallback, max: fallback, min: fallback, }; } const values = series.map((point) => point.value); const sum = values.reduce((acc, value) => acc + value, 0); return { average: Math.round(sum / values.length), max: Math.round(Math.max(...values)), min: Math.round(Math.min(...values)), }; } function calculateHeartRateZones(series: WorkoutHeartRatePoint[]): HeartRateZoneStat[] { if (!series.length) { return HEART_RATE_ZONES.map((zone) => ({ key: zone.key, label: zone.label, color: zone.color, rangeText: zone.rangeText, durationMinutes: 0, percentage: 0, })); } const durations: Record = HEART_RATE_ZONES.reduce((acc, zone) => { acc[zone.key] = 0; return acc; }, {} as Record); for (let i = 0; i < series.length; i++) { const current = series[i]; const next = series[i + 1]; const currentTime = dayjs(current.timestamp); let nextTime = next ? dayjs(next.timestamp) : currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second'); if (!nextTime.isValid() || nextTime.isBefore(currentTime)) { nextTime = currentTime.add(DEFAULT_SAMPLE_GAP_SECONDS, 'second'); } const durationSeconds = Math.max(nextTime.diff(currentTime, 'second'), DEFAULT_SAMPLE_GAP_SECONDS); const zone = getZoneForValue(current.value); durations[zone.key] += durationSeconds; } const totalSeconds = Object.values(durations).reduce((acc, value) => acc + value, 0) || 1; return HEART_RATE_ZONES.map((zone) => { const zoneSeconds = durations[zone.key] || 0; const minutes = Math.round(zoneSeconds / 60); const percentage = Math.round((zoneSeconds / totalSeconds) * 100); return { key: zone.key, label: zone.label, color: zone.color, rangeText: zone.rangeText, durationMinutes: minutes, percentage, }; }); } function getZoneForValue(value: number) { return ( HEART_RATE_ZONES.find((zone) => value >= zone.min && value < zone.max) || HEART_RATE_ZONES[HEART_RATE_ZONES.length - 1] ); } function extractMetsFromMetadata(metadata: Record): number | null { if (!metadata) { return null; } const candidates = [ metadata.HKAverageMETs, metadata.averageMETs, metadata.mets, metadata.METs, ]; for (const candidate of candidates) { if (candidate !== undefined && candidate !== null && Number.isFinite(Number(candidate))) { return Math.round(Number(candidate) * 10) / 10; } } return null; } function calculateMetsFromWorkoutData(workout: WorkoutData): number | null { // 如果没有卡路里消耗或持续时间数据,无法计算 METs if (!workout.totalEnergyBurned || !workout.duration || workout.duration <= 0) { return null; } // 计算活动能耗(千卡/小时) const durationInHours = workout.duration / 3600; // 将秒转换为小时 const activeEnergyBurnedPerHour = workout.totalEnergyBurned / durationInHours; // 使用估算的平均体重(70公斤)来计算 METs // METs = 活动能量消耗(千卡/小时) ÷ 体重(千克) const estimatedWeightKg = 70; // 成年人平均估算体重 const mets = activeEnergyBurnedPerHour / estimatedWeightKg; // 验证计算结果的合理性 // 一般成年人的静息代谢率约为 1 MET,日常活动通常在 1-12 METs 范围内 // 高强度运动可能超过 12 METs,但很少超过 20 METs if (mets < 0.5 || mets > 25) { console.warn('计算出的 METs 值可能不合理:', { mets, totalEnergyBurned: workout.totalEnergyBurned, duration: workout.duration, durationInHours, activeEnergyBurnedPerHour, estimatedWeightKg }); // 即使值可能不合理,也返回计算结果,但记录警告 } // 保留一位小数 return Math.round(mets * 10) / 10; }