feat(health): 新增手腕温度监测和经期双向同步功能

新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
This commit is contained in:
richarjiang
2025-12-18 08:40:08 +08:00
parent 9b4a300380
commit 4836058d56
31 changed files with 2249 additions and 539 deletions

View File

@@ -1,4 +1,5 @@
import dayjs, { Dayjs } from 'dayjs';
import { MenstrualFlowSample } from './health';
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
@@ -54,18 +55,42 @@ const STATUS_PRIORITY: Record<MenstrualDayStatus, number> = {
export const DEFAULT_CYCLE_LENGTH = 28;
export const DEFAULT_PERIOD_LENGTH = 5;
const MIN_CYCLE_LENGTH = 21;
const MAX_CYCLE_LENGTH = 45;
const LOOKBACK_WINDOW = 6;
const LUTEAL_PHASE_MEAN = 13; // 1214 天之间较为稳定
export const createDefaultRecords = (): CycleRecord[] => {
const today = dayjs();
const latestStart = today.subtract(4, 'day'); // 默认让今天处于经期第5天
const previousStart = latestStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
const olderStart = previousStart.subtract(DEFAULT_CYCLE_LENGTH, 'day');
const clampCycleLength = (value: number, fallback: number) => {
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.max(MIN_CYCLE_LENGTH, Math.min(MAX_CYCLE_LENGTH, Math.round(value)));
};
return [
{ startDate: olderStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
{ startDate: previousStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
{ startDate: latestStart.format('YYYY-MM-DD'), periodLength: DEFAULT_PERIOD_LENGTH },
];
const calcMedian = (values: number[]) => {
if (!values.length) return undefined;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2;
}
return sorted[mid];
};
const calcTrimmedMean = (values: number[], trim = 1) => {
if (!values.length) return undefined;
const sorted = [...values].sort((a, b) => a - b);
const start = Math.min(trim, sorted.length);
const end = Math.max(sorted.length - trim, start);
const sliced = sorted.slice(start, end);
if (!sliced.length) return undefined;
return sliced.reduce((sum, cur) => sum + cur, 0) / sliced.length;
};
const calcStdDev = (values: number[]) => {
if (values.length < 2) return 0;
const mean = values.reduce((s, v) => s + v, 0) / values.length;
const variance =
values.reduce((s, v) => s + (v - mean) * (v - mean), 0) / (values.length - 1);
return Math.sqrt(variance);
};
const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE_LENGTH) => {
@@ -81,8 +106,11 @@ const calcAverageCycleLength = (records: CycleRecord[], fallback = DEFAULT_CYCLE
}
}
if (!intervals.length) return fallback;
const avg = intervals.reduce((sum, cur) => sum + cur, 0) / intervals.length;
return Math.round(avg);
const recent = intervals.slice(-LOOKBACK_WINDOW);
const median = calcMedian(recent);
const trimmed = calcTrimmedMean(recent);
const blended = median ?? trimmed ?? calcTrimmedMean(intervals) ?? fallback;
return clampCycleLength(blended, fallback);
};
const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERIOD_LENGTH) => {
@@ -90,8 +118,11 @@ const calcAveragePeriodLength = (records: CycleRecord[], fallback = DEFAULT_PERI
.map((r) => r.periodLength)
.filter((l): l is number => typeof l === 'number' && l > 0);
if (!lengths.length) return fallback;
const avg = lengths.reduce((sum, cur) => sum + cur, 0) / lengths.length;
return Math.round(avg);
const recent = lengths.slice(-LOOKBACK_WINDOW);
const median = calcMedian(recent);
const trimmed = calcTrimmedMean(recent);
const blended = median ?? trimmed ?? calcTrimmedMean(lengths) ?? fallback;
return Math.max(3, Math.round(blended)); // 极端短周期时仍保持生理期合理下限
};
const addDayInfo = (
@@ -110,9 +141,10 @@ const addDayInfo = (
};
const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => {
// 默认排卵日位于周期的中间偏后,兼容短/长周期
const daysFromStart = Math.max(12, Math.round(cycleLength / 2));
return cycleStart.add(daysFromStart, 'day');
// 排卵日约在下次月经前 12-14 天,较稳定;对极端周期做边界约束
const daysFromStart = cycleLength - LUTEAL_PHASE_MEAN;
const offset = Math.min(Math.max(daysFromStart, 11), 16);
return cycleStart.add(offset, 'day');
};
export const buildMenstrualTimeline = (options?: {
@@ -136,6 +168,23 @@ export const buildMenstrualTimeline = (options?: {
options?.defaultCycleLength ?? calcAverageCycleLength(records, DEFAULT_CYCLE_LENGTH);
const avgPeriodLength =
options?.defaultPeriodLength ?? calcAveragePeriodLength(records, DEFAULT_PERIOD_LENGTH);
const sortedStarts = [...records]
.sort((a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf())
.map((r) => dayjs(r.startDate));
const cycleIntervals: number[] = [];
for (let i = 1; i < sortedStarts.length; i += 1) {
const diff = sortedStarts[i].diff(sortedStarts[i - 1], 'day');
if (diff > 0) cycleIntervals.push(diff);
}
const recentIntervals = cycleIntervals.slice(-LOOKBACK_WINDOW);
const cycleVariability = calcStdDev(recentIntervals);
const lastInterval =
recentIntervals.length > 0 ? recentIntervals[recentIntervals.length - 1] : avgCycleLength;
// 对未来预测使用稳健均值 + 最新趋势的折中,以避免单次异常牵动过大
const predictedCycleLength = clampCycleLength(
0.55 * avgCycleLength + 0.45 * lastInterval,
avgCycleLength
);
const cycles = records.map((record) => ({
start: dayjs(record.startDate),
@@ -144,19 +193,36 @@ export const buildMenstrualTimeline = (options?: {
cycleLength: record.cycleLength ?? avgCycleLength,
}));
// 基于真实相邻开始日期矫正已记录周期长度,便于后续计算排卵日/易孕期
for (let i = 0; i < cycles.length; i += 1) {
const next = cycles[i + 1];
if (next) {
const interval = next.start.diff(cycles[i].start, 'day');
if (interval > 0) {
cycles[i].cycleLength = clampCycleLength(interval, cycles[i].cycleLength);
}
}
}
// 只有当存在历史记录时,才进行后续预测
if (cycles.length > 0) {
const lastConfirmed = cycles[cycles.length - 1];
let cursorStart = lastConfirmed.start;
let nextCycleLength = predictedCycleLength;
while (cursorStart.isBefore(endMonth)) {
cursorStart = cursorStart.add(avgCycleLength, 'day');
cursorStart = cursorStart.add(nextCycleLength, 'day');
cycles.push({
start: cursorStart,
confirmed: false,
periodLength: avgPeriodLength,
cycleLength: avgCycleLength,
cycleLength: nextCycleLength,
});
// 趋势逐步回归稳健均值,避免长期漂移
nextCycleLength = clampCycleLength(
Math.round(nextCycleLength * 0.65 + avgCycleLength * 0.35),
avgCycleLength
);
}
}
@@ -164,7 +230,9 @@ export const buildMenstrualTimeline = (options?: {
cycles.forEach((cycle) => {
const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength);
const fertileStart = ovulationDay.subtract(5, 'day');
// 经前 luteal 稳定,易孕窗口 5-7 天:排卵日前 5 天 + 排卵日当日;高波动人群额外向前扩 1 天
const fertileWindow = Math.min(7, 6 + (cycle.confirmed ? 0 : Math.round(cycleVariability)));
const fertileStart = ovulationDay.subtract(fertileWindow, 'day');
for (let i = 0; i < cycle.periodLength; i += 1) {
const date = cycle.start.add(i, 'day');
@@ -177,7 +245,7 @@ export const buildMenstrualTimeline = (options?: {
});
}
for (let i = 0; i < 5; i += 1) {
for (let i = 0; i < fertileWindow; i += 1) {
const date = fertileStart.add(i, 'day');
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
addDayInfo(dayMap, date, {
@@ -245,7 +313,7 @@ export const buildMenstrualTimeline = (options?: {
return {
months,
dayMap,
cycleLength: avgCycleLength,
cycleLength: predictedCycleLength,
periodLength: avgPeriodLength,
todayInfo: dayMap[todayKey],
};
@@ -258,3 +326,62 @@ export const getMenstrualSummaryForDate = (
const key = date.format('YYYY-MM-DD');
return dayMap[key];
};
export const convertHealthKitSamplesToCycleRecords = (
samples: MenstrualFlowSample[]
): CycleRecord[] => {
if (!samples.length) return [];
// 1. Sort samples by date
const sortedSamples = [...samples].sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
const records: CycleRecord[] = [];
let currentStart: Dayjs | null = null;
let currentEnd: Dayjs | null = null;
// 2. Iterate and merge consecutive days
for (const sample of sortedSamples) {
const sampleDate = dayjs(sample.startDate);
// If we have no current period being tracked, start a new one
if (!currentStart) {
currentStart = sampleDate;
currentEnd = sampleDate;
continue;
}
// Check if this sample is contiguous with the current period
// Allow 1 day gap? Standard logic usually assumes contiguous days for a single period.
// However, spotting might cause gaps. For now, we'll merge if gap <= 1 day.
const diff = sampleDate.diff(currentEnd, 'day');
if (diff <= 1) {
// Extend current period
currentEnd = sampleDate;
} else {
// Gap is too large, finalize current period and start new one
if (currentStart && currentEnd) {
records.push({
startDate: currentStart.format('YYYY-MM-DD'),
periodLength: currentEnd.diff(currentStart, 'day') + 1,
source: 'healthkit',
});
}
currentStart = sampleDate;
currentEnd = sampleDate;
}
}
// Push the last record
if (currentStart && currentEnd) {
records.push({
startDate: currentStart.format('YYYY-MM-DD'),
periodLength: currentEnd.diff(currentStart, 'day') + 1,
source: 'healthkit',
});
}
return records;
};