feat(workout): 重构锻炼模块并新增详细数据展示

- 移除旧的锻炼会话页面和布局文件
- 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示
- 优化锻炼历史页面,增加月度统计卡片和交互式详情查看
- 新增锻炼详情服务,提供心率分析、METs计算等功能
- 更新应用版本至1.0.17并调整iOS后台任务配置
- 添加项目规则文档,明确React Native开发规范
This commit is contained in:
richarjiang
2025-10-11 17:20:51 +08:00
parent 79ddd41a49
commit d43d8c692f
13 changed files with 1605 additions and 2417 deletions

279
services/workoutDetail.ts Normal file
View File

@@ -0,0 +1,279 @@
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;
}