feat(workout): 重构锻炼模块并新增详细数据展示
- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
This commit is contained in:
279
services/workoutDetail.ts
Normal file
279
services/workoutDetail.ts
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user