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>
99 lines
3.0 KiB
TypeScript
99 lines
3.0 KiB
TypeScript
// ABOUTME: Cycle phase calculation utilities.
|
|
// ABOUTME: Determines current cycle day and phase from last period date with variable cycle lengths.
|
|
import type { CyclePhase, PhaseConfig } from "@/types";
|
|
|
|
// Base phase configurations with weekly limits and training guidance.
|
|
// Note: The 'days' field is for the default 31-day cycle; actual boundaries
|
|
// are calculated dynamically by getPhaseBoundaries() based on cycleLength.
|
|
export const PHASE_CONFIGS: PhaseConfig[] = [
|
|
{
|
|
name: "MENSTRUAL",
|
|
days: [1, 3],
|
|
weeklyLimit: 30,
|
|
dailyAvg: 10,
|
|
trainingType: "Gentle rebounding only",
|
|
},
|
|
{
|
|
name: "FOLLICULAR",
|
|
days: [4, 15],
|
|
weeklyLimit: 120,
|
|
dailyAvg: 17,
|
|
trainingType: "Strength + rebounding",
|
|
},
|
|
{
|
|
name: "OVULATION",
|
|
days: [16, 17],
|
|
weeklyLimit: 80,
|
|
dailyAvg: 40,
|
|
trainingType: "Peak performance",
|
|
},
|
|
{
|
|
name: "EARLY_LUTEAL",
|
|
days: [18, 24],
|
|
weeklyLimit: 100,
|
|
dailyAvg: 14,
|
|
trainingType: "Moderate training",
|
|
},
|
|
{
|
|
name: "LATE_LUTEAL",
|
|
days: [25, 31],
|
|
weeklyLimit: 50,
|
|
dailyAvg: 8,
|
|
trainingType: "Gentle rebounding ONLY",
|
|
},
|
|
];
|
|
|
|
// Phase boundaries scale based on cycle length using fixed luteal, variable follicular.
|
|
// Per spec: luteal phase is biologically consistent (14 days); follicular expands/contracts.
|
|
// Formula from specs/cycle-tracking.md:
|
|
// MENSTRUAL: 1-3 (fixed)
|
|
// FOLLICULAR: 4 to (cycleLength - 16)
|
|
// OVULATION: (cycleLength - 15) to (cycleLength - 14)
|
|
// EARLY_LUTEAL: (cycleLength - 13) to (cycleLength - 7)
|
|
// LATE_LUTEAL: (cycleLength - 6) to cycleLength
|
|
function getPhaseBoundaries(
|
|
cycleLength: number,
|
|
): Array<{ phase: CyclePhase; start: number; end: number }> {
|
|
return [
|
|
{ phase: "MENSTRUAL", start: 1, end: 3 },
|
|
{ phase: "FOLLICULAR", start: 4, end: cycleLength - 16 },
|
|
{ phase: "OVULATION", start: cycleLength - 15, end: cycleLength - 14 },
|
|
{ phase: "EARLY_LUTEAL", start: cycleLength - 13, end: cycleLength - 7 },
|
|
{ phase: "LATE_LUTEAL", start: cycleLength - 6, end: cycleLength },
|
|
];
|
|
}
|
|
|
|
export function getCycleDay(
|
|
lastPeriodDate: Date,
|
|
cycleLength: number,
|
|
currentDate: Date = new Date(),
|
|
): number {
|
|
const diffMs = currentDate.getTime() - lastPeriodDate.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
return (diffDays % cycleLength) + 1;
|
|
}
|
|
|
|
export function getPhase(cycleDay: number, cycleLength = 31): CyclePhase {
|
|
const boundaries = getPhaseBoundaries(cycleLength);
|
|
|
|
for (const { phase, start, end } of boundaries) {
|
|
if (cycleDay >= start && cycleDay <= end) {
|
|
return phase;
|
|
}
|
|
}
|
|
// Default to late luteal for any days beyond cycle length
|
|
return "LATE_LUTEAL";
|
|
}
|
|
|
|
export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
|
|
const config = PHASE_CONFIGS.find((c) => c.name === phase);
|
|
if (!config) {
|
|
throw new Error(`Unknown phase: ${phase}`);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
export function getPhaseLimit(phase: CyclePhase): number {
|
|
return getPhaseConfig(phase).weeklyLimit;
|
|
}
|