- 新增生理周期追踪页面及相关算法逻辑,支持经期记录与预测 - 新增首页统计卡片自定义页面,支持VIP用户调整卡片显示状态与顺序 - 重构首页统计页面布局逻辑,支持动态渲染与混合布局 - 引入 react-native-draggable-flatlist 用于实现拖拽排序功能 - 添加相关多语言配置及用户偏好设置存储接口
261 lines
7.4 KiB
TypeScript
261 lines
7.4 KiB
TypeScript
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<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;
|
|
|
|
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<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) => {
|
|
// 默认排卵日位于周期的中间偏后,兼容短/长周期
|
|
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<string, MenstrualDayInfo> = {};
|
|
|
|
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<string, MenstrualDayInfo>
|
|
) => {
|
|
const key = date.format('YYYY-MM-DD');
|
|
return dayMap[key];
|
|
};
|