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>
This commit is contained in:
2026-01-11 22:39:09 +00:00
parent 58f6c5605a
commit a977934c23
15 changed files with 337 additions and 148 deletions

View File

@@ -1,7 +1,10 @@
// ABOUTME: Cycle phase calculation utilities.
// ABOUTME: Determines current cycle day and phase from last period date.
// 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",
@@ -12,21 +15,21 @@ export const PHASE_CONFIGS: PhaseConfig[] = [
},
{
name: "FOLLICULAR",
days: [4, 14],
days: [4, 15],
weeklyLimit: 120,
dailyAvg: 17,
trainingType: "Strength + rebounding",
},
{
name: "OVULATION",
days: [15, 16],
days: [16, 17],
weeklyLimit: 80,
dailyAvg: 40,
trainingType: "Peak performance",
},
{
name: "EARLY_LUTEAL",
days: [17, 24],
days: [18, 24],
weeklyLimit: 100,
dailyAvg: 14,
trainingType: "Moderate training",
@@ -40,6 +43,26 @@ export const PHASE_CONFIGS: PhaseConfig[] = [
},
];
// 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,
@@ -50,13 +73,15 @@ export function getCycleDay(
return (diffDays % cycleLength) + 1;
}
export function getPhase(cycleDay: number): CyclePhase {
for (const config of PHASE_CONFIGS) {
if (cycleDay >= config.days[0] && cycleDay <= config.days[1]) {
return config.name;
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 31
// Default to late luteal for any days beyond cycle length
return "LATE_LUTEAL";
}