import dayjs, { Dayjs } from 'dayjs'; import 'dayjs/locale/en'; import 'dayjs/locale/zh-cn'; 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; cycleLength: number; periodLength: number; todayInfo?: MenstrualDayInfo; }; const STATUS_PRIORITY: Record = { '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, 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; locale?: 'zh' | 'en'; monthTitleFormat?: string; monthSubtitleFormat?: string; }): 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 = {}; 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'); const locale = options?.locale ?? 'zh'; const localeKey = locale === 'en' ? 'en' : 'zh-cn'; const monthTitleFormat = options?.monthTitleFormat ?? (locale === 'en' ? 'MMM' : 'M月'); const monthSubtitleFormat = options?.monthSubtitleFormat ?? (locale === 'en' ? 'YYYY' : 'YYYY年'); 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}`, }); } const formattedMonth = firstDay.locale(localeKey); months.push({ id: firstDay.format('YYYY-MM'), title: formattedMonth.format(monthTitleFormat), subtitle: formattedMonth.format(monthSubtitleFormat), 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 ) => { 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; };