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',
},
});