CRITICAL BUG FIX: - Phase boundaries were hardcoded for 31-day cycle, breaking correct phase calculations for users with different cycle lengths (28, 35, etc.) - Added getPhaseBoundaries(cycleLength) function in cycle.ts - Updated getPhase() to accept cycleLength parameter (default 31) - Updated all callers (API routes, components) to pass cycleLength - Added 13 new tests for phase boundaries with 28, 31, and 35-day cycles ICS IMPROVEMENTS: - Fixed emojis to match calendar.md spec: 🩸🌱🌸🌙🌑 - Added CATEGORIES field for calendar app colors per spec: MENSTRUAL=Red, FOLLICULAR=Green, OVULATION=Pink, EARLY_LUTEAL=Yellow, LATE_LUTEAL=Orange - Added 5 new tests for CATEGORIES Updated IMPLEMENTATION_PLAN.md with discovered issues and test counts. 825 tests passing (up from 807) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
155 lines
4.3 KiB
TypeScript
155 lines
4.3 KiB
TypeScript
// 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<string, string> = {
|
|
MENSTRUAL: "🩸",
|
|
FOLLICULAR: "🌱",
|
|
OVULATION: "🌸",
|
|
EARLY_LUTEAL: "🌙",
|
|
LATE_LUTEAL: "🌑",
|
|
};
|
|
|
|
// Phase color categories per calendar.md spec for calendar app color coding
|
|
const PHASE_CATEGORIES: Record<string, string> = {
|
|
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];
|
|
}
|