import { LinearGradient } from 'expo-linear-gradient'; import dayjs, { Dayjs } from 'dayjs'; import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { Colors } from '@/constants/Colors'; import { fetchMenstrualFlowSamples } from '@/utils/health'; import { buildMenstrualTimeline, convertHealthKitSamplesToCycleRecords, CycleRecord, DEFAULT_PERIOD_LENGTH, MenstrualDayInfo, MenstrualDayStatus, MenstrualTimeline, } from '@/utils/menstrualCycle'; type Props = { onPress?: () => void; }; type Summary = { state: string; prefix?: string; suffix?: string; number?: number; fallbackText: string; }; const RingIcon = () => ( ); export const MenstrualCycleCard: React.FC = ({ onPress }) => { const { t } = useTranslation(); const [records, setRecords] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { let mounted = true; const loadMenstrualData = async () => { setLoading(true); try { const today = dayjs(); const startDate = today.subtract(3, 'month').startOf('month').toDate(); const endDate = today.add(4, 'month').endOf('month').toDate(); const samples = await fetchMenstrualFlowSamples(startDate, endDate); if (!mounted) return; const converted = convertHealthKitSamplesToCycleRecords(samples); setRecords(converted); } catch (error) { console.error('Failed to load menstrual flow samples', error); if (mounted) { setRecords([]); } } finally { if (mounted) { setLoading(false); } } }; loadMenstrualData(); return () => { mounted = false; }; }, []); const timeline = useMemo( () => buildMenstrualTimeline({ records, monthsBefore: 2, monthsAfter: 4, defaultPeriodLength: DEFAULT_PERIOD_LENGTH, }), [records] ); const summary = useMemo(() => { if (loading && records.length === 0) { return { state: t('menstrual.card.syncingState'), fallbackText: t('menstrual.card.syncingDesc'), }; } return deriveSummary(timeline, records.length > 0, t); }, [loading, records.length, timeline, t]); return ( {t('menstrual.card.title')} {summary.state} {summary.number !== undefined ? ( <> {summary.prefix} {summary.number} {summary.suffix} ) : ( summary.fallbackText )} ); }; const periodStatuses = new Set(['period', 'predicted-period']); const fertileStatuses = new Set(['fertile', 'ovulation-day']); const ovulationStatuses = new Set(['ovulation-day']); const deriveSummary = ( timeline: MenstrualTimeline, hasRecords: boolean, t: (key: string, options?: Record) => string ): Summary => { const today = dayjs(); const { dayMap, todayInfo } = timeline; if (!hasRecords || !Object.keys(dayMap).length) { return { state: t('menstrual.card.emptyState'), fallbackText: t('menstrual.card.emptyDesc'), }; } const sortedInfos = Object.values(dayMap).sort( (a, b) => a.date.valueOf() - b.date.valueOf() ); const findContinuousRange = ( date: Dayjs, targetStatuses: Set ): { start: Dayjs; end: Dayjs } | null => { const key = date.format('YYYY-MM-DD'); if (!targetStatuses.has(dayMap[key]?.status)) return null; let start = date; let end = date; while (true) { const prev = start.subtract(1, 'day'); const prevInfo = dayMap[prev.format('YYYY-MM-DD')]; if (prevInfo && targetStatuses.has(prevInfo.status)) { start = prev; } else { break; } } while (true) { const next = end.add(1, 'day'); const nextInfo = dayMap[next.format('YYYY-MM-DD')]; if (nextInfo && targetStatuses.has(nextInfo.status)) { end = next; } else { break; } } return { start, end }; }; const findFutureStatus = ( targetStatuses: Set, inclusive = true ): MenstrualDayInfo | undefined => { return sortedInfos.find((info) => { const isInRange = inclusive ? !info.date.isBefore(today, 'day') : info.date.isAfter(today, 'day'); return isInRange && targetStatuses.has(info.status); }); }; const findPastStatus = (targetStatuses: Set) => { for (let i = sortedInfos.length - 1; i >= 0; i -= 1) { const info = sortedInfos[i]; if (!info.date.isAfter(today, 'day') && targetStatuses.has(info.status)) { return info; } } return undefined; }; if (todayInfo && periodStatuses.has(todayInfo.status)) { const range = findContinuousRange(today, periodStatuses); const end = range?.end ?? today; const daysLeft = Math.max(end.diff(today, 'day'), 0); if (daysLeft === 0) { return { state: todayInfo.status === 'period' ? t('menstrual.card.periodState') : t('menstrual.card.predictedPeriodState'), fallbackText: t('menstrual.card.periodEndToday', { date: end.format(t('menstrual.dateFormatShort')), }), }; } return { state: todayInfo.status === 'period' ? t('menstrual.card.periodState') : t('menstrual.card.predictedPeriodState'), prefix: t('menstrual.card.periodEndPrefix'), number: daysLeft, suffix: t('menstrual.card.periodEndSuffix', { date: end.format(t('menstrual.dateFormatShort')), }), fallbackText: '', }; } const nextPeriod = findFutureStatus(periodStatuses, false); const lastPeriodInfo = findPastStatus(periodStatuses); const lastPeriodStart = lastPeriodInfo ? findContinuousRange(lastPeriodInfo.date, periodStatuses)?.start : undefined; const ovulationThisCycle = sortedInfos.find((info) => { if (!ovulationStatuses.has(info.status)) return false; if (lastPeriodStart && info.date.isBefore(lastPeriodStart, 'day')) return false; if (nextPeriod && !info.date.isBefore(nextPeriod.date, 'day')) return false; return true; }); if (todayInfo?.status === 'fertile') { const targetOvulation = ovulationThisCycle ?? findFutureStatus(ovulationStatuses); if (targetOvulation) { const days = Math.max(targetOvulation.date.diff(today, 'day'), 0); if (days === 0) { return { state: t('menstrual.card.fertileState'), fallbackText: t('menstrual.card.ovulationToday'), }; } return { state: t('menstrual.card.fertileState'), prefix: t('menstrual.card.ovulationCountdownPrefix'), number: days, suffix: t('menstrual.card.ovulationCountdownSuffix'), fallbackText: '', }; } } const nextFertile = findFutureStatus(fertileStatuses); if (nextFertile && (!nextPeriod || nextFertile.date.isBefore(nextPeriod.date))) { const days = Math.max(nextFertile.date.diff(today, 'day'), 0); if (days === 0) { return { state: t('menstrual.card.fertileState'), fallbackText: t('menstrual.card.fertileToday'), }; } return { state: t('menstrual.card.fertileState'), prefix: t('menstrual.card.fertileCountdownPrefix'), number: days, suffix: t('menstrual.card.fertileCountdownSuffix'), fallbackText: '', }; } if ( ovulationThisCycle && nextPeriod && today.isAfter(ovulationThisCycle.date, 'day') && today.isBefore(nextPeriod.date, 'day') ) { const days = Math.max(nextPeriod.date.diff(today, 'day'), 0); return { state: t('menstrual.card.periodState'), prefix: t('menstrual.card.nextPeriodPrefix'), number: days, suffix: t('menstrual.card.nextPeriodSuffix'), fallbackText: '', }; } if (nextPeriod) { const days = Math.max(nextPeriod.date.diff(today, 'day'), 0); return { state: t('menstrual.card.periodState'), prefix: t('menstrual.card.nextPeriodPrefix'), number: days, suffix: t('menstrual.card.nextPeriodSuffix'), fallbackText: '', }; } return { state: t('menstrual.card.emptyState'), fallbackText: t('menstrual.card.emptyDesc'), }; }; const styles = StyleSheet.create({ wrapper: { width: '100%', }, headerRow: { flexDirection: 'row', alignItems: 'center', gap: 10, }, iconWrapper: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center', }, iconGradient: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center', }, iconInner: { width: 10, height: 10, borderRadius: 5, backgroundColor: '#fff', }, title: { fontSize: 14, color: '#192126', fontWeight: '600', flex: 1, fontFamily: 'AliBold', }, badgeOuter: { width: 18, height: 18, borderRadius: 9, borderWidth: 2, borderColor: '#fbcfe8', alignItems: 'center', justifyContent: 'center', }, badgeInner: { width: 6, height: 6, borderRadius: 3, backgroundColor: Colors.light.primary, opacity: 0.35, }, content: { marginTop: 12, }, stateText: { fontSize: 12, color: '#515558', marginBottom: 4, fontFamily: 'AliRegular', }, dayRow: { fontSize: 14, color: '#192126', fontFamily: 'AliRegular', }, dayNumber: { fontSize: 18, fontWeight: '700', color: '#192126', fontFamily: 'AliBold', }, });