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

@@ -4,15 +4,15 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
## Current State Summary
### Overall Status: 807 tests passing across 43 test files
### Overall Status: 825 tests passing across 43 test files
### Library Implementation
| File | Status | Gap Analysis |
|------|--------|--------------|
| `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready |
| `cycle.ts` | **COMPLETE** | 22 tests covering all functions including dynamic phase boundaries for variable cycle lengths |
| `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance |
| `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines |
| `ics.ts` | **COMPLETE** | 28 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling, period prediction feedback |
| `ics.ts` | **COMPLETE** | 33 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling, period prediction feedback, CATEGORIES for calendar colors |
| `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation |
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
| `garmin.ts` | **COMPLETE** | 33 tests covering fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, isTokenExpired, daysUntilExpiry, error handling, token validation |
@@ -78,7 +78,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
### Test Coverage
| Test File | Status |
|-----------|--------|
| `src/lib/cycle.test.ts` | **EXISTS** - 9 tests |
| `src/lib/cycle.test.ts` | **EXISTS** - 22 tests |
| `src/lib/decision-engine.test.ts` | **EXISTS** - 24 tests (8 algorithmic rules + 16 override scenarios) |
| `src/lib/pocketbase.test.ts` | **EXISTS** - 9 tests (auth helpers, cookie loading) |
| `src/lib/auth-middleware.test.ts` | **EXISTS** - 9 tests (withAuth wrapper, error handling, structured logging) |
@@ -94,7 +94,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/page.test.tsx` | **EXISTS** - 28 tests (data fetching, component rendering, override toggles, error handling) |
| `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) |
| `src/lib/email.test.ts` | **EXISTS** - 24 tests (email content, subject lines, formatting, token expiration warnings) |
| `src/lib/ics.test.ts` | **EXISTS** - 28 tests (ICS format validation, 90-day event generation, timezone handling, period prediction feedback) |
| `src/lib/ics.test.ts` | **EXISTS** - 33 tests (ICS format validation, 90-day event generation, timezone handling, period prediction feedback, CATEGORIES for colors) |
| `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) |
| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) |
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
@@ -871,12 +871,12 @@ P4.* UX Polish ────────> After core functionality complete
## Completed
### Library
- [x] **cycle.ts** - Complete with 9 tests (`getCycleDay`, `getPhase`, `getPhaseConfig`, `getPhaseLimit`)
- [x] **cycle.ts** - Complete with 22 tests (`getCycleDay`, `getPhase` with dynamic boundaries for variable cycle lengths, `getPhaseConfig`, `getPhaseLimit`)
- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`)
- [x] **nutrition.ts** - Complete with 17 tests (`getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific carb ranges, keto guidance) (P3.2)
- [x] **email.ts** - Complete with 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9)
- [x] **ics.ts** - Complete with 28 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback) (P3.4, P4.5)
- [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5)
- [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5)
- [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6)
- [x] **auth-middleware.ts** - Complete with 6 tests (`withAuth()` wrapper)
@@ -949,6 +949,9 @@ P4.* UX Polish ────────> After core functionality complete
*Bugs and inconsistencies found during implementation*
- [x] ~~**CRITICAL: Cycle phase boundaries hardcoded for 31-day cycle**~~ - FIXED. Phase boundaries were not scaling with cycle length. The spec (cycle-tracking.md) defines formulas: MENSTRUAL 1-3, FOLLICULAR 4-(cycleLength-16), OVULATION (cycleLength-15)-(cycleLength-14), EARLY_LUTEAL (cycleLength-13)-(cycleLength-7), LATE_LUTEAL (cycleLength-6)-cycleLength. Added `getPhaseBoundaries(cycleLength)` function and updated `getPhase()` to accept cycleLength parameter. Updated all callers (API routes, components) to pass cycleLength. Added 13 new tests.
- [x] ~~ICS emojis did not match calendar.md spec~~ - FIXED. Changed from colored circles (🔵🟢🟣🟡🔴) to thematic emojis (🩸🌱🌸🌙🌑) per spec.
- [x] ~~ICS missing CATEGORIES field for calendar app colors~~ - FIXED. Added CATEGORIES field per calendar.md spec: MENSTRUAL=Red, FOLLICULAR=Green, OVULATION=Pink, EARLY_LUTEAL=Yellow, LATE_LUTEAL=Orange. Added 5 new tests.
- [x] ~~`src/lib/auth-middleware.ts` does not exist~~ - CREATED in P0.2
- [x] ~~`src/middleware.ts` does not exist~~ - CREATED in P0.2
- [x] ~~`garmin.ts` is only ~30% complete - missing specific biometric fetchers~~ - FIXED in P2.1 (added fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes)

View File

@@ -101,7 +101,7 @@ export async function POST(request: Request) {
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, user.cycleLength);
const phaseLimit = getPhaseLimit(phase);
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);

View File

@@ -118,13 +118,14 @@ describe("GET /api/cycle/current", () => {
expect(body.phaseConfig.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120);
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
expect(body.phaseConfig.days).toEqual([4, 14]);
// Phase configs days are for reference; actual boundaries are calculated dynamically
expect(body.phaseConfig.days).toEqual([4, 15]);
expect(body.phaseConfig.dailyAvg).toBe(17);
});
it("calculates daysUntilNextPhase correctly", async () => {
// Cycle day 10, in FOLLICULAR (days 4-14)
// Days until OVULATION starts (day 15): 15 - 10 = 5
// Cycle day 10, in FOLLICULAR (days 4-15 for 31-day cycle)
// Days until OVULATION starts (day 16): 16 - 10 = 6
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,
@@ -135,7 +136,7 @@ describe("GET /api/cycle/current", () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body.daysUntilNextPhase).toBe(5);
expect(body.daysUntilNextPhase).toBe(6);
});
it("returns correct data for MENSTRUAL phase", async () => {
@@ -157,10 +158,11 @@ describe("GET /api/cycle/current", () => {
});
it("returns correct data for OVULATION phase", async () => {
// Set lastPeriodDate so cycle day = 15 (start of OVULATION)
// If current is 2025-01-10, need lastPeriodDate = 2024-12-27 (14 days ago)
// For 31-day cycle, OVULATION is days 16-17
// Set lastPeriodDate so cycle day = 16 (start of OVULATION)
// If current is 2025-01-10, need lastPeriodDate = 2024-12-26 (15 days ago)
currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-27"),
lastPeriodDate: new Date("2024-12-26"),
cycleLength: 31,
});
@@ -169,10 +171,10 @@ describe("GET /api/cycle/current", () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body.cycleDay).toBe(15);
expect(body.cycleDay).toBe(16);
expect(body.phase).toBe("OVULATION");
expect(body.phaseConfig.weeklyLimit).toBe(80);
expect(body.daysUntilNextPhase).toBe(2); // Day 17 is EARLY_LUTEAL
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
});
it("returns correct data for LATE_LUTEAL phase", async () => {

View File

@@ -3,36 +3,41 @@
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-middleware";
import {
getCycleDay,
getPhase,
getPhaseConfig,
PHASE_CONFIGS,
} from "@/lib/cycle";
import { getCycleDay, getPhase, getPhaseConfig } from "@/lib/cycle";
// Phase boundaries per spec: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
function getNextPhaseStart(currentPhase: string, cycleLength: number): number {
switch (currentPhase) {
case "MENSTRUAL":
return 4; // FOLLICULAR starts at 4
case "FOLLICULAR":
return cycleLength - 15; // OVULATION starts at (cycleLength - 15)
case "OVULATION":
return cycleLength - 13; // EARLY_LUTEAL starts at (cycleLength - 13)
case "EARLY_LUTEAL":
return cycleLength - 6; // LATE_LUTEAL starts at (cycleLength - 6)
case "LATE_LUTEAL":
return 1; // New cycle starts
default:
return 1;
}
}
/**
* Calculates the number of days until the next phase begins.
* For LATE_LUTEAL, calculates days until new cycle starts (MENSTRUAL).
*/
function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
const currentPhase = getPhase(cycleDay);
const currentConfig = getPhaseConfig(currentPhase);
const currentPhase = getPhase(cycleDay, cycleLength);
// For LATE_LUTEAL, calculate days until new cycle
if (currentPhase === "LATE_LUTEAL") {
return cycleLength - cycleDay + 1;
}
// Find next phase start day
const currentIndex = PHASE_CONFIGS.findIndex((c) => c.name === currentPhase);
const nextConfig = PHASE_CONFIGS[currentIndex + 1];
if (nextConfig) {
return nextConfig.days[0] - cycleDay;
}
// Fallback: days until end of current phase + 1
return currentConfig.days[1] - cycleDay + 1;
const nextPhaseStart = getNextPhaseStart(currentPhase, cycleLength);
return nextPhaseStart - cycleDay;
}
export const GET = withAuth(async (_request, user) => {
@@ -53,7 +58,7 @@ export const GET = withAuth(async (_request, user) => {
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, user.cycleLength);
const phaseConfig = getPhaseConfig(phase);
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength);

View File

@@ -89,7 +89,7 @@ export const POST = withAuth(async (request: NextRequest, user) => {
// Calculate updated cycle information
const lastPeriodDate = new Date(body.startDate);
const cycleDay = getCycleDay(lastPeriodDate, user.cycleLength, new Date());
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, user.cycleLength);
// Calculate prediction accuracy
let daysEarly: number | undefined;

View File

@@ -354,8 +354,8 @@ describe("GET /api/today", () => {
});
it("returns days until next phase", async () => {
// Cycle day 10 in FOLLICULAR (days 4-14)
// Next phase (OVULATION) starts day 15, so 5 days away
// Cycle day 10 in FOLLICULAR (days 4-15 for 31-day cycle)
// Next phase (OVULATION) starts day 16, so 6 days away
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"),
});
@@ -366,7 +366,7 @@ describe("GET /api/today", () => {
expect(response.status).toBe(200);
const body = await response.json();
expect(body.daysUntilNextPhase).toBe(5);
expect(body.daysUntilNextPhase).toBe(6);
});
});

View File

@@ -8,7 +8,6 @@ import {
getPhase,
getPhaseConfig,
getPhaseLimit,
PHASE_CONFIGS,
} from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { logger } from "@/lib/logger";
@@ -47,21 +46,25 @@ export const GET = withAuth(async (_request, user) => {
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, user.cycleLength);
const phaseConfig = getPhaseConfig(phase);
const phaseLimit = getPhaseLimit(phase);
// Calculate days until next phase
const currentPhaseIndex = PHASE_CONFIGS.findIndex((c) => c.name === phase);
const nextPhaseIndex = (currentPhaseIndex + 1) % PHASE_CONFIGS.length;
const nextPhaseStartDay = PHASE_CONFIGS[nextPhaseIndex].days[0];
// Calculate days until next phase using dynamic boundaries
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
let daysUntilNextPhase: number;
if (nextPhaseIndex === 0) {
// Currently in LATE_LUTEAL, next phase is MENSTRUAL (start of new cycle)
if (phase === "LATE_LUTEAL") {
daysUntilNextPhase = user.cycleLength - cycleDay + 1;
} else if (phase === "MENSTRUAL") {
daysUntilNextPhase = 4 - cycleDay;
} else if (phase === "FOLLICULAR") {
daysUntilNextPhase = user.cycleLength - 15 - cycleDay;
} else if (phase === "OVULATION") {
daysUntilNextPhase = user.cycleLength - 13 - cycleDay;
} else {
daysUntilNextPhase = nextPhaseStartDay - cycleDay;
// EARLY_LUTEAL
daysUntilNextPhase = user.cycleLength - 6 - cycleDay;
}
// Try to fetch today's DailyLog for biometrics

View File

@@ -71,8 +71,9 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Jan 15 is "today" - aria-label includes date, cycle day, and phase
// For 28-day cycle, day 15 is EARLY_LUTEAL (days 15-21)
const todayCell = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
expect(todayCell).toHaveClass("ring-2", "ring-black");
});
@@ -99,40 +100,40 @@ describe("MonthView", () => {
expect(day1).toHaveClass("bg-blue-100");
});
it("applies follicular phase color to days 4-14", () => {
it("applies follicular phase color to days 4-12", () => {
render(<MonthView {...baseProps} />);
// Day 5 is FOLLICULAR (bg-green-100)
// For 28-day cycle, FOLLICULAR is days 4-12
const day5 = screen.getByRole("button", {
name: /January 5, 2026 - Cycle day 5 - Follicular phase/i,
});
expect(day5).toHaveClass("bg-green-100");
});
it("applies ovulation phase color to days 15-16", () => {
it("applies ovulation phase color to days 13-14", () => {
render(<MonthView {...baseProps} />);
// Day 15 is OVULATION (bg-purple-100)
const day15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase/i,
// For 28-day cycle, OVULATION is days 13-14
const day13 = screen.getByRole("button", {
name: /January 13, 2026 - Cycle day 13 - Ovulation phase/i,
});
expect(day15).toHaveClass("bg-purple-100");
expect(day13).toHaveClass("bg-purple-100");
});
it("applies early luteal phase color to days 17-24", () => {
it("applies early luteal phase color to days 15-21", () => {
render(<MonthView {...baseProps} />);
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
const day20 = screen.getByRole("button", {
name: /January 20, 2026 - Cycle day 20 - Early Luteal phase/i,
// For 28-day cycle, EARLY_LUTEAL is days 15-21
const day18 = screen.getByRole("button", {
name: /January 18, 2026 - Cycle day 18 - Early Luteal phase/i,
});
expect(day20).toHaveClass("bg-yellow-100");
expect(day18).toHaveClass("bg-yellow-100");
});
it("applies late luteal phase color to days 25-31", () => {
it("applies late luteal phase color to days 22-28", () => {
render(<MonthView {...baseProps} />);
// Day 25 is LATE_LUTEAL (bg-red-100)
// For 28-day cycle, LATE_LUTEAL is days 22-28
const day25 = screen.getByRole("button", {
name: /January 25, 2026 - Cycle day 25 - Late Luteal phase/i,
});
@@ -267,20 +268,22 @@ describe("MonthView", () => {
});
describe("keyboard navigation", () => {
// For 28-day cycle:
// MENSTRUAL: 1-3, FOLLICULAR: 4-12, OVULATION: 13-14, EARLY_LUTEAL: 15-21, LATE_LUTEAL: 22-28
it("moves focus to next day when pressing ArrowRight", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowRight to move to Jan 16
// Press ArrowRight to move to Jan 16 - day 16 is EARLY_LUTEAL
fireEvent.keyDown(jan15, { key: "ArrowRight" });
const jan16 = screen.getByRole("button", {
name: /January 16, 2026 - Cycle day 16 - Ovulation phase$/i,
name: /January 16, 2026 - Cycle day 16 - Early Luteal phase$/i,
});
expect(document.activeElement).toBe(jan16);
});
@@ -288,17 +291,17 @@ describe("MonthView", () => {
it("moves focus to previous day when pressing ArrowLeft", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowLeft to move to Jan 14
// Press ArrowLeft to move to Jan 14 - day 14 is OVULATION
fireEvent.keyDown(jan15, { key: "ArrowLeft" });
const jan14 = screen.getByRole("button", {
name: /January 14, 2026 - Cycle day 14 - Follicular phase$/i,
name: /January 14, 2026 - Cycle day 14 - Ovulation phase$/i,
});
expect(document.activeElement).toBe(jan14);
});
@@ -306,17 +309,17 @@ describe("MonthView", () => {
it("moves focus to same day next week when pressing ArrowDown", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowDown to move to Jan 22 (7 days later)
// Press ArrowDown to move to Jan 22 (7 days later) - day 22 is LATE_LUTEAL
fireEvent.keyDown(jan15, { key: "ArrowDown" });
const jan22 = screen.getByRole("button", {
name: /January 22, 2026 - Cycle day 22 - Early Luteal phase$/i,
name: /January 22, 2026 - Cycle day 22 - Late Luteal phase$/i,
});
expect(document.activeElement).toBe(jan22);
});
@@ -324,13 +327,13 @@ describe("MonthView", () => {
it("moves focus to same day previous week when pressing ArrowUp", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
// Press ArrowUp to move to Jan 8 (7 days earlier)
// Press ArrowUp to move to Jan 8 (7 days earlier) - day 8 is FOLLICULAR
fireEvent.keyDown(jan15, { key: "ArrowUp" });
const jan8 = screen.getByRole("button", {
@@ -375,9 +378,9 @@ describe("MonthView", () => {
it("wraps focus at row boundaries for Home and End keys", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();
@@ -393,9 +396,9 @@ describe("MonthView", () => {
it("moves focus to last day when pressing End key", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
});
jan15.focus();

View File

@@ -204,7 +204,7 @@ export function MonthView({
}
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, cycleLength);
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&

View File

@@ -34,9 +34,10 @@ describe("MiniCalendar", () => {
it("renders current cycle day and phase", () => {
render(<MiniCalendar {...baseProps} />);
// Jan 15, 2026 with lastPeriod Jan 1 = Day 15 (OVULATION)
// Jan 15, 2026 with lastPeriod Jan 1 = Day 15
// For 28-day cycle: EARLY_LUTEAL starts at day 15 (28-13=15)
expect(screen.getByText(/Day 15/)).toBeInTheDocument();
expect(screen.getByText(/OVULATION/)).toBeInTheDocument();
expect(screen.getByText(/EARLY_LUTEAL/)).toBeInTheDocument();
});
it("renders compact day-of-week headers", () => {
@@ -86,6 +87,8 @@ describe("MiniCalendar", () => {
});
describe("phase colors", () => {
// For 28-day cycle:
// MENSTRUAL: 1-3, FOLLICULAR: 4-12, OVULATION: 13-14, EARLY_LUTEAL: 15-21, LATE_LUTEAL: 22-28
it("applies menstrual phase color (blue) to days 1-3", () => {
render(<MiniCalendar {...baseProps} />);
@@ -95,37 +98,37 @@ describe("MiniCalendar", () => {
expect(day1).toHaveClass("bg-blue-100");
});
it("applies follicular phase color (green) to days 4-14", () => {
it("applies follicular phase color (green) to days 4-12", () => {
render(<MiniCalendar {...baseProps} />);
// Day 5 is FOLLICULAR (bg-green-100)
// Day 5 is FOLLICULAR (bg-green-100) for 28-day cycle
const buttons = screen.getAllByRole("button");
const day5 = buttons.find((btn) => btn.textContent === "5");
expect(day5).toHaveClass("bg-green-100");
});
it("applies ovulation phase color (purple) to days 15-16", () => {
it("applies ovulation phase color (purple) to days 13-14", () => {
render(<MiniCalendar {...baseProps} />);
// Day 15 is OVULATION (bg-purple-100)
// Day 13 is OVULATION (bg-purple-100) for 28-day cycle
const buttons = screen.getAllByRole("button");
const day15 = buttons.find((btn) => btn.textContent === "15");
expect(day15).toHaveClass("bg-purple-100");
const day13 = buttons.find((btn) => btn.textContent === "13");
expect(day13).toHaveClass("bg-purple-100");
});
it("applies early luteal phase color (yellow) to days 17-24", () => {
it("applies early luteal phase color (yellow) to days 15-21", () => {
render(<MiniCalendar {...baseProps} />);
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
// Day 18 is EARLY_LUTEAL (bg-yellow-100) for 28-day cycle
const buttons = screen.getAllByRole("button");
const day20 = buttons.find((btn) => btn.textContent === "20");
expect(day20).toHaveClass("bg-yellow-100");
const day18 = buttons.find((btn) => btn.textContent === "18");
expect(day18).toHaveClass("bg-yellow-100");
});
it("applies late luteal phase color (red) to days 25-31", () => {
it("applies late luteal phase color (red) to days 22-28", () => {
render(<MiniCalendar {...baseProps} />);
// Day 25 is LATE_LUTEAL (bg-red-100)
// Day 25 is LATE_LUTEAL (bg-red-100) for 28-day cycle
const buttons = screen.getAllByRole("button");
const day25 = buttons.find((btn) => btn.textContent === "25");
expect(day25).toHaveClass("bg-red-100");

View File

@@ -55,7 +55,7 @@ export function MiniCalendar({
// Calculate current cycle day and phase for the header
const currentCycleDay = getCycleDay(lastPeriodDate, cycleLength, today);
const currentPhase = getPhase(currentCycleDay);
const currentPhase = getPhase(currentCycleDay, cycleLength);
const handlePreviousMonth = () => {
if (displayMonth === 0) {
@@ -154,7 +154,7 @@ export function MiniCalendar({
}
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
const phase = getPhase(cycleDay);
const phase = getPhase(cycleDay, cycleLength);
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&

View File

@@ -1,5 +1,5 @@
// ABOUTME: Unit tests for cycle phase calculation utilities.
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions.
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions with variable cycle lengths.
import { describe, expect, it } from "vitest";
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
@@ -25,29 +25,133 @@ describe("getCycleDay", () => {
});
describe("getPhase", () => {
// Phase boundaries per spec (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
describe("31-day cycle", () => {
const cycleLength = 31;
it("returns MENSTRUAL for days 1-3", () => {
expect(getPhase(1)).toBe("MENSTRUAL");
expect(getPhase(3)).toBe("MENSTRUAL");
expect(getPhase(1, cycleLength)).toBe("MENSTRUAL");
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
});
it("returns FOLLICULAR for days 4-14", () => {
expect(getPhase(4)).toBe("FOLLICULAR");
expect(getPhase(14)).toBe("FOLLICULAR");
it("returns FOLLICULAR for days 4-15", () => {
// 4 to (31-16) = 4-15
expect(getPhase(4, cycleLength)).toBe("FOLLICULAR");
expect(getPhase(15, cycleLength)).toBe("FOLLICULAR");
});
it("returns OVULATION for days 15-16", () => {
expect(getPhase(15)).toBe("OVULATION");
expect(getPhase(16)).toBe("OVULATION");
it("returns OVULATION for days 16-17", () => {
// (31-15) to (31-14) = 16-17
expect(getPhase(16, cycleLength)).toBe("OVULATION");
expect(getPhase(17, cycleLength)).toBe("OVULATION");
});
it("returns EARLY_LUTEAL for days 17-24", () => {
expect(getPhase(17)).toBe("EARLY_LUTEAL");
expect(getPhase(24)).toBe("EARLY_LUTEAL");
it("returns EARLY_LUTEAL for days 18-24", () => {
// (31-13) to (31-7) = 18-24
expect(getPhase(18, cycleLength)).toBe("EARLY_LUTEAL");
expect(getPhase(24, cycleLength)).toBe("EARLY_LUTEAL");
});
it("returns LATE_LUTEAL for days 25-31", () => {
expect(getPhase(25)).toBe("LATE_LUTEAL");
expect(getPhase(31)).toBe("LATE_LUTEAL");
// (31-6) to 31 = 25-31
expect(getPhase(25, cycleLength)).toBe("LATE_LUTEAL");
expect(getPhase(31, cycleLength)).toBe("LATE_LUTEAL");
});
});
describe("28-day cycle", () => {
const cycleLength = 28;
it("returns MENSTRUAL for days 1-3", () => {
expect(getPhase(1, cycleLength)).toBe("MENSTRUAL");
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
});
it("returns FOLLICULAR for days 4-12", () => {
// 4 to (28-16) = 4-12
expect(getPhase(4, cycleLength)).toBe("FOLLICULAR");
expect(getPhase(12, cycleLength)).toBe("FOLLICULAR");
});
it("returns OVULATION for days 13-14", () => {
// (28-15) to (28-14) = 13-14
expect(getPhase(13, cycleLength)).toBe("OVULATION");
expect(getPhase(14, cycleLength)).toBe("OVULATION");
});
it("returns EARLY_LUTEAL for days 15-21", () => {
// (28-13) to (28-7) = 15-21
expect(getPhase(15, cycleLength)).toBe("EARLY_LUTEAL");
expect(getPhase(21, cycleLength)).toBe("EARLY_LUTEAL");
});
it("returns LATE_LUTEAL for days 22-28", () => {
// (28-6) to 28 = 22-28
expect(getPhase(22, cycleLength)).toBe("LATE_LUTEAL");
expect(getPhase(28, cycleLength)).toBe("LATE_LUTEAL");
});
});
describe("35-day cycle", () => {
const cycleLength = 35;
it("returns MENSTRUAL for days 1-3", () => {
expect(getPhase(1, cycleLength)).toBe("MENSTRUAL");
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
});
it("returns FOLLICULAR for days 4-19", () => {
// 4 to (35-16) = 4-19
expect(getPhase(4, cycleLength)).toBe("FOLLICULAR");
expect(getPhase(19, cycleLength)).toBe("FOLLICULAR");
});
it("returns OVULATION for days 20-21", () => {
// (35-15) to (35-14) = 20-21
expect(getPhase(20, cycleLength)).toBe("OVULATION");
expect(getPhase(21, cycleLength)).toBe("OVULATION");
});
it("returns EARLY_LUTEAL for days 22-28", () => {
// (35-13) to (35-7) = 22-28
expect(getPhase(22, cycleLength)).toBe("EARLY_LUTEAL");
expect(getPhase(28, cycleLength)).toBe("EARLY_LUTEAL");
});
it("returns LATE_LUTEAL for days 29-35", () => {
// (35-6) to 35 = 29-35
expect(getPhase(29, cycleLength)).toBe("LATE_LUTEAL");
expect(getPhase(35, cycleLength)).toBe("LATE_LUTEAL");
});
});
describe("edge cases", () => {
it("defaults to LATE_LUTEAL for days beyond cycle length", () => {
expect(getPhase(32, 31)).toBe("LATE_LUTEAL");
expect(getPhase(40, 35)).toBe("LATE_LUTEAL");
});
it("handles minimum cycle length (21 days)", () => {
// 21-day: FOLLICULAR 4-5, OVULATION 6-7, EARLY_LUTEAL 8-14, LATE_LUTEAL 15-21
expect(getPhase(5, 21)).toBe("FOLLICULAR"); // 4 to (21-16)=5
expect(getPhase(6, 21)).toBe("OVULATION"); // (21-15)=6 to (21-14)=7
expect(getPhase(8, 21)).toBe("EARLY_LUTEAL"); // (21-13)=8 to (21-7)=14
expect(getPhase(15, 21)).toBe("LATE_LUTEAL"); // (21-6)=15 to 21
});
it("handles maximum cycle length (45 days)", () => {
// 45-day: FOLLICULAR 4-29, OVULATION 30-31, EARLY_LUTEAL 32-38, LATE_LUTEAL 39-45
expect(getPhase(29, 45)).toBe("FOLLICULAR"); // 4 to (45-16)=29
expect(getPhase(30, 45)).toBe("OVULATION"); // (45-15)=30 to (45-14)=31
expect(getPhase(32, 45)).toBe("EARLY_LUTEAL"); // (45-13)=32 to (45-7)=38
expect(getPhase(39, 45)).toBe("LATE_LUTEAL"); // (45-6)=39 to 45
});
});
});

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

View File

@@ -36,27 +36,27 @@ describe("generateIcsFeed", () => {
describe("phase events", () => {
it("includes MENSTRUAL phase events", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🔵 MENSTRUAL");
expect(ics).toContain("🩸 MENSTRUAL");
});
it("includes FOLLICULAR phase events", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🟢 FOLLICULAR");
expect(ics).toContain("🌱 FOLLICULAR");
});
it("includes OVULATION phase events", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🟣 OVULATION");
expect(ics).toContain("🌸 OVULATION");
});
it("includes EARLY_LUTEAL phase events", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🟡 EARLY LUTEAL");
expect(ics).toContain("🌙 EARLY LUTEAL");
});
it("includes LATE_LUTEAL phase events", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🔴 LATE LUTEAL");
expect(ics).toContain("🌑 LATE LUTEAL");
});
});
@@ -81,7 +81,7 @@ describe("generateIcsFeed", () => {
cycleLength: 31,
});
// Should contain multiple cycles worth of events
const menstrualCount = (ics.match(/🔵 MENSTRUAL/g) || []).length;
const menstrualCount = (ics.match(/🩸 MENSTRUAL/g) || []).length;
expect(menstrualCount).toBeGreaterThanOrEqual(3);
});
@@ -108,8 +108,8 @@ describe("generateIcsFeed", () => {
monthsAhead: 2,
});
// Should complete at least one full cycle
expect(ics).toContain("🔵 MENSTRUAL");
expect(ics).toContain("🔴 LATE LUTEAL");
expect(ics).toContain("🩸 MENSTRUAL");
expect(ics).toContain("🌑 LATE LUTEAL");
});
it("handles shorter 28-day cycle", () => {
@@ -119,8 +119,8 @@ describe("generateIcsFeed", () => {
monthsAhead: 2,
});
// Should still contain all phases
expect(ics).toContain("🔵 MENSTRUAL");
expect(ics).toContain("🟢 FOLLICULAR");
expect(ics).toContain("🩸 MENSTRUAL");
expect(ics).toContain("🌱 FOLLICULAR");
});
it("handles longer 35-day cycle", () => {
@@ -130,8 +130,8 @@ describe("generateIcsFeed", () => {
monthsAhead: 2,
});
// Should still contain all phases
expect(ics).toContain("🔵 MENSTRUAL");
expect(ics).toContain("🟢 FOLLICULAR");
expect(ics).toContain("🩸 MENSTRUAL");
expect(ics).toContain("🌱 FOLLICULAR");
});
});
@@ -198,6 +198,34 @@ describe("generateIcsFeed", () => {
});
});
describe("calendar color coding via categories", () => {
it("includes CATEGORIES field for MENSTRUAL phase (Red)", () => {
const ics = generateIcsFeed(defaultOptions);
// Find MENSTRUAL events and verify they have Red category
expect(ics).toMatch(/SUMMARY:🩸 MENSTRUAL[\s\S]*?CATEGORIES:Red/);
});
it("includes CATEGORIES field for FOLLICULAR phase (Green)", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toMatch(/SUMMARY:🌱 FOLLICULAR[\s\S]*?CATEGORIES:Green/);
});
it("includes CATEGORIES field for OVULATION phase (Pink)", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toMatch(/SUMMARY:🌸 OVULATION[\s\S]*?CATEGORIES:Pink/);
});
it("includes CATEGORIES field for EARLY_LUTEAL phase (Yellow)", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toMatch(/SUMMARY:🌙 EARLY LUTEAL[\s\S]*?CATEGORIES:Yellow/);
});
it("includes CATEGORIES field for LATE_LUTEAL phase (Orange)", () => {
const ics = generateIcsFeed(defaultOptions);
expect(ics).toMatch(/SUMMARY:🌑 LATE LUTEAL[\s\S]*?CATEGORIES:Orange/);
});
});
describe("prediction accuracy feedback", () => {
it("generates predicted event when period arrived early", () => {
const periodLogs: PeriodLog[] = [
@@ -218,7 +246,7 @@ describe("generateIcsFeed", () => {
});
// Should contain both actual and predicted menstrual events
expect(ics).toContain("🔵 MENSTRUAL (Predicted)");
expect(ics).toContain("🩸 MENSTRUAL (Predicted)");
expect(ics).toContain("Original prediction");
expect(ics).toContain("period arrived 2 days early");
});
@@ -242,7 +270,7 @@ describe("generateIcsFeed", () => {
});
// Should contain both actual and predicted menstrual events
expect(ics).toContain("🔵 MENSTRUAL (Predicted)");
expect(ics).toContain("🩸 MENSTRUAL (Predicted)");
expect(ics).toContain("Original prediction");
expect(ics).toContain("period arrived 3 days late");
});

View File

@@ -5,12 +5,22 @@ 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: "🔴",
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 {
@@ -35,12 +45,13 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
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);
const phase = getPhase(cycleDay, cycleLength);
// Add warning events
if (cycleDay === 22) {
@@ -107,7 +118,7 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
events.push({
start: dateToArray(predicted),
end: dateToArray(predictedEnd),
title: "🔵 MENSTRUAL (Predicted)",
title: "🩸 MENSTRUAL (Predicted)",
description,
});
}
@@ -127,12 +138,14 @@ function createPhaseEvent(
): 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,
};
}