新增手腕温度健康数据追踪,支持Apple Watch睡眠手腕温度数据展示和30天历史趋势分析 实现经期数据与HealthKit的完整双向同步,支持读取、写入和删除经期记录 优化经期预测算法,基于历史数据计算更准确的周期和排卵日预测 重构经期UI组件为模块化结构,提升代码可维护性 添加完整的中英文国际化支持,覆盖所有新增功能界面
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
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; // 12–14 天之间较为稳定
|
||
|
||
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;
|
||
};
|