Files
digital-pilates/services/workoutDetail.ts
richarjiang d43d8c692f feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件
- 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示
- 优化锻炼历史页面,增加月度统计卡片和交互式详情查看
- 新增锻炼详情服务,提供心率分析、METs计算等功能
- 更新应用版本至1.0.17并调整iOS后台任务配置
- 添加项目规则文档,明确React Native开发规范
2025-10-11 17:20:51 +08:00

280 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<WorkoutDetailMetrics> {
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<string, number> = HEART_RATE_ZONES.reduce((acc, zone) => {
acc[zone.key] = 0;
return acc;
}, {} as Record<string, number>);
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<string, any>): 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;
}