// ABOUTME: ICS calendar feed generation for cycle phase events. // ABOUTME: Creates subscribable calendar with phase blocks and warnings. import { createEvents, type EventAttributes } from "ics"; import type { PeriodLog } from "@/types"; import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle"; // Phase emojis per calendar.md spec const PHASE_EMOJIS: Record = { MENSTRUAL: "🩸", FOLLICULAR: "🌱", OVULATION: "🌸", EARLY_LUTEAL: "🌙", LATE_LUTEAL: "🌑", }; // Phase color categories per calendar.md spec for calendar app color coding const PHASE_CATEGORIES: Record = { MENSTRUAL: "Red", FOLLICULAR: "Green", OVULATION: "Pink", EARLY_LUTEAL: "Yellow", LATE_LUTEAL: "Orange", }; interface IcsGeneratorOptions { lastPeriodDate: Date; cycleLength: number; monthsAhead?: number; periodLogs?: PeriodLog[]; } export function generateIcsFeed(options: IcsGeneratorOptions): string { const { lastPeriodDate, cycleLength, monthsAhead = 3, periodLogs = [], } = options; const events: EventAttributes[] = []; const endDate = new Date(); endDate.setMonth(endDate.getMonth() + monthsAhead); const currentDate = new Date(lastPeriodDate); let currentPhase = getPhase( getCycleDay(lastPeriodDate, cycleLength, currentDate), cycleLength, ); let phaseStartDate = new Date(currentDate); while (currentDate <= endDate) { const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate); const phase = getPhase(cycleDay, cycleLength); // Add warning events if (cycleDay === 22) { events.push({ start: dateToArray(currentDate), end: dateToArray(currentDate), title: "⚠️ Late Luteal Phase Starts in 3 Days", description: "Begin reducing training intensity", }); } if (cycleDay === 25) { events.push({ start: dateToArray(currentDate), end: dateToArray(currentDate), title: "🔴 CRITICAL PHASE - Gentle Rebounding Only!", description: "Late luteal phase - protect your cycle", }); } // Track phase changes if (phase !== currentPhase) { // Close previous phase event events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate)); currentPhase = phase; phaseStartDate = new Date(currentDate); } currentDate.setDate(currentDate.getDate() + 1); } // Close final phase events.push(createPhaseEvent(currentPhase, phaseStartDate, currentDate)); // Add predicted vs actual events from period logs for (const log of periodLogs) { if (!log.predictedDate) { continue; // Skip logs without prediction (first log) } const actual = new Date(log.startDate); const predicted = new Date(log.predictedDate); // Calculate difference in days const diffMs = predicted.getTime() - actual.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); // Only show predicted event if dates differ if (diffDays === 0) { continue; } // Generate predicted menstrual event const predictedEnd = new Date(predicted); predictedEnd.setDate(predicted.getDate() + 3); // Menstrual phase is 3 days let description: string; if (diffDays > 0) { description = `Original prediction - period arrived ${diffDays} days early`; } else { description = `Original prediction - period arrived ${Math.abs(diffDays)} days late`; } events.push({ start: dateToArray(predicted), end: dateToArray(predictedEnd), title: "🩸 MENSTRUAL (Predicted)", description, }); } const { value, error } = createEvents(events); if (error) { throw new Error(`ICS generation error: ${error}`); } return value || ""; } function createPhaseEvent( phase: string, startDate: Date, endDate: Date, ): EventAttributes { const config = PHASE_CONFIGS.find((c) => c.name === phase); const emoji = PHASE_EMOJIS[phase] || "📅"; const category = PHASE_CATEGORIES[phase]; return { start: dateToArray(startDate), end: dateToArray(endDate), title: `${emoji} ${phase.replace("_", " ")}`, description: config?.trainingType || "", categories: category ? [category] : undefined, }; } function dateToArray(date: Date): [number, number, number, number, number] { return [date.getFullYear(), date.getMonth() + 1, date.getDate(), 0, 0]; }