Files
phaseflow/src/lib/ics.ts
Petru Paler a977934c23 Fix critical bug: cycle phase boundaries now scale with cycle length
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>
2026-01-11 22:39:09 +00:00

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];
}