import dayjs, { Dayjs } from 'dayjs'; 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; 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'); 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 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 avg = intervals.reduce((sum, cur) => sum + cur, 0) / intervals.length; return Math.round(avg); }; 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 avg = lengths.reduce((sum, cur) => sum + cur, 0) / lengths.length; return Math.round(avg); }; 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) => { // 默认排卵日位于周期的中间偏后,兼容短/长周期 const daysFromStart = Math.max(12, Math.round(cycleLength / 2)); return cycleStart.add(daysFromStart, '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 cycles = records.map((record) => ({ start: dayjs(record.startDate), confirmed: true, periodLength: record.periodLength ?? avgPeriodLength, cycleLength: record.cycleLength ?? avgCycleLength, })); // 只有当存在历史记录时,才进行后续预测 if (cycles.length > 0) { const lastConfirmed = cycles[cycles.length - 1]; let cursorStart = lastConfirmed.start; while (cursorStart.isBefore(endMonth)) { cursorStart = cursorStart.add(avgCycleLength, 'day'); cycles.push({ start: cursorStart, confirmed: false, periodLength: avgPeriodLength, cycleLength: avgCycleLength, }); } } const dayMap: Record = {}; cycles.forEach((cycle) => { const ovulationDay = getOvulationDay(cycle.start, cycle.cycleLength); const fertileStart = ovulationDay.subtract(5, '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 < 5; 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: avgCycleLength, periodLength: avgPeriodLength, todayInfo: dayMap[todayKey], }; }; export const getMenstrualSummaryForDate = ( date: Dayjs, dayMap: Record ) => { const key = date.format('YYYY-MM-DD'); return dayMap[key]; };