- 移除旧的锻炼会话页面和布局文件 - 新增锻炼详情模态框组件,支持心率区间、运动强度等详细数据展示 - 优化锻炼历史页面,增加月度统计卡片和交互式详情查看 - 新增锻炼详情服务,提供心率分析、METs计算等功能 - 更新应用版本至1.0.17并调整iOS后台任务配置 - 添加项目规则文档,明确React Native开发规范
280 lines
7.4 KiB
TypeScript
280 lines
7.4 KiB
TypeScript
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;
|
||
}
|
||
|