Files
digital-pilates/utils/menstrualCycle.ts
richarjiang 4836058d56 feat(health): 新增手腕温度监测和经期双向同步功能
新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析
实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录
优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测
重构经期UI组件为模块化结构,提升代码可维护性
添加完整的中英文国际化支持,覆盖所有新增功能界面
2025-12-18 08:40:08 +08:00

388 lines
12 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, { Dayjs } from 'dayjs';
import { MenstrualFlowSample } from './health';
export type MenstrualDayStatus = 'period' | 'predicted-period' | 'fertile' | 'ovulation-day';
export type CycleRecord = {
startDate: string;
periodLength?: number;
cycleLength?: number;
source?: 'healthkit' | 'manual';
};
export type MenstrualDayInfo = {
date: Dayjs;
status: MenstrualDayStatus;
confirmed: boolean;
dayOfCycle?: number;
};
export type MenstrualDayCell =
| {
type: 'placeholder';
key: string;
}
| {
type: 'day';
key: string;
label: number;
date: Dayjs;
info?: MenstrualDayInfo;
isToday: boolean;
};
export type MenstrualMonth = {
id: string;
title: string;
subtitle: string;
cells: MenstrualDayCell[];
};
export type MenstrualTimeline = {
months: MenstrualMonth[];
dayMap: Record<string, MenstrualDayInfo>;
cycleLength: number;
periodLength: number;
todayInfo?: MenstrualDayInfo;
};
const STATUS_PRIORITY: Record<MenstrualDayStatus, number> = {
'ovulation-day': 4,
period: 3,
'predicted-period': 2,
fertile: 1,
};
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 天之间较为稳定
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)));
};
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) => {
if (records.length < 2) return fallback;
const sorted = [...records].sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
const intervals: number[] = [];
for (let i = 1; i < sorted.length; i += 1) {
const diff = dayjs(sorted[i].startDate).diff(dayjs(sorted[i - 1].startDate), 'day');
if (diff > 0) {
intervals.push(diff);
}
}
if (!intervals.length) return fallback;
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) => {
const lengths = records
.map((r) => r.periodLength)
.filter((l): l is number => typeof l === 'number' && l > 0);
if (!lengths.length) return fallback;
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 = (
dayMap: Record<string, MenstrualDayInfo>,
date: Dayjs,
info: MenstrualDayInfo
) => {
const key = date.format('YYYY-MM-DD');
const existing = dayMap[key];
if (existing && STATUS_PRIORITY[existing.status] >= STATUS_PRIORITY[info.status]) {
return;
}
dayMap[key] = info;
};
const getOvulationDay = (cycleStart: Dayjs, cycleLength: number) => {
// 排卵日约在下次月经前 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?: {
records?: CycleRecord[];
monthsBefore?: number;
monthsAfter?: number;
defaultCycleLength?: number;
defaultPeriodLength?: number;
}): MenstrualTimeline => {
const today = dayjs();
const monthsBefore = options?.monthsBefore ?? 2;
const monthsAfter = options?.monthsAfter ?? 3;
const startMonth = today.subtract(monthsBefore, 'month').startOf('month');
const endMonth = today.add(monthsAfter, 'month').endOf('month');
const records = (options?.records ?? []).sort(
(a, b) => dayjs(a.startDate).valueOf() - dayjs(b.startDate).valueOf()
);
const avgCycleLength =
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),
confirmed: true,
periodLength: record.periodLength ?? avgPeriodLength,
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(nextCycleLength, 'day');
cycles.push({
start: cursorStart,
confirmed: false,
periodLength: avgPeriodLength,
cycleLength: nextCycleLength,
});
// 趋势逐步回归稳健均值,避免长期漂移
nextCycleLength = clampCycleLength(
Math.round(nextCycleLength * 0.65 + avgCycleLength * 0.35),
avgCycleLength
);
}
}
const dayMap: Record<string, MenstrualDayInfo> = {};
cycles.forEach((cycle) => {
const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength);
// 经前 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');
if (date.isBefore(startMonth) || date.isAfter(endMonth)) continue;
addDayInfo(dayMap, date, {
date,
status: cycle.confirmed ? 'period' : 'predicted-period',
confirmed: cycle.confirmed,
dayOfCycle: 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, {
date,
status: 'fertile',
confirmed: cycle.confirmed,
});
}
if (!ovulationDay.isBefore(startMonth) && !ovulationDay.isAfter(endMonth)) {
addDayInfo(dayMap, ovulationDay, {
date: ovulationDay,
status: 'ovulation-day',
confirmed: cycle.confirmed,
});
}
});
const months: MenstrualMonth[] = [];
let monthCursor = startMonth.startOf('month');
while (monthCursor.isBefore(endMonth) || monthCursor.isSame(endMonth, 'month')) {
const firstDay = monthCursor.startOf('month');
const daysInMonth = firstDay.daysInMonth();
// 以周一为周首,符合设计稿呈现
const firstWeekday = (firstDay.day() + 6) % 7; // 0(一) - 6(日)
const cells: MenstrualDayCell[] = [];
for (let i = 0; i < firstWeekday; i += 1) {
cells.push({ type: 'placeholder', key: `${firstDay.format('YYYY-MM')}-p-${i}` });
}
for (let day = 1; day <= daysInMonth; day += 1) {
const date = firstDay.date(day);
const key = date.format('YYYY-MM-DD');
cells.push({
type: 'day',
key,
label: day,
date,
info: dayMap[key],
isToday: date.isSame(today, 'day'),
});
}
while (cells.length % 7 !== 0) {
cells.push({
type: 'placeholder',
key: `${firstDay.format('YYYY-MM')}-t-${cells.length}`,
});
}
months.push({
id: firstDay.format('YYYY-MM'),
title: firstDay.format('M月'),
subtitle: firstDay.format('YYYY年'),
cells,
});
monthCursor = monthCursor.add(1, 'month');
}
const todayKey = today.format('YYYY-MM-DD');
return {
months,
dayMap,
cycleLength: predictedCycleLength,
periodLength: avgPeriodLength,
todayInfo: dayMap[todayKey],
};
};
export const getMenstrualSummaryForDate = (
date: Dayjs,
dayMap: Record<string, MenstrualDayInfo>
) => {
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;
};