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 ## Current State Summary
### Overall Status: 807 tests passing across 43 test files ### Overall Status: 825 tests passing across 43 test files
### Library Implementation ### Library Implementation
| File | Status | Gap Analysis | | 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 | | `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 | | `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 | | `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 | | `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 | | `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 Coverage
| Test File | Status | | 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/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/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) | | `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/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/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/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/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/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) | | `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 ## Completed
### Library ### 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] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`) - [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] **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] **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] **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] **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) - [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* *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/lib/auth-middleware.ts` does not exist~~ - CREATED in P0.2
- [x] ~~`src/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) - [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, user.cycleLength,
new Date(), new Date(),
); );
const phase = getPhase(cycleDay); const phase = getPhase(cycleDay, user.cycleLength);
const phaseLimit = getPhaseLimit(phase); const phaseLimit = getPhaseLimit(phase);
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes); 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.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120); expect(body.phaseConfig.weeklyLimit).toBe(120);
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding"); 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); expect(body.phaseConfig.dailyAvg).toBe(17);
}); });
it("calculates daysUntilNextPhase correctly", async () => { it("calculates daysUntilNextPhase correctly", async () => {
// Cycle day 10, in FOLLICULAR (days 4-14) // Cycle day 10, in FOLLICULAR (days 4-15 for 31-day cycle)
// Days until OVULATION starts (day 15): 15 - 10 = 5 // Days until OVULATION starts (day 16): 16 - 10 = 6
currentMockUser = createMockUser({ currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"), lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31, cycleLength: 31,
@@ -135,7 +136,7 @@ describe("GET /api/cycle/current", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json(); const body = await response.json();
expect(body.daysUntilNextPhase).toBe(5); expect(body.daysUntilNextPhase).toBe(6);
}); });
it("returns correct data for MENSTRUAL phase", async () => { 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 () => { it("returns correct data for OVULATION phase", async () => {
// Set lastPeriodDate so cycle day = 15 (start of OVULATION) // For 31-day cycle, OVULATION is days 16-17
// If current is 2025-01-10, need lastPeriodDate = 2024-12-27 (14 days ago) // 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({ currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-27"), lastPeriodDate: new Date("2024-12-26"),
cycleLength: 31, cycleLength: 31,
}); });
@@ -169,10 +171,10 @@ describe("GET /api/cycle/current", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json(); const body = await response.json();
expect(body.cycleDay).toBe(15); expect(body.cycleDay).toBe(16);
expect(body.phase).toBe("OVULATION"); expect(body.phase).toBe("OVULATION");
expect(body.phaseConfig.weeklyLimit).toBe(80); 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 () => { it("returns correct data for LATE_LUTEAL phase", async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,9 +34,10 @@ describe("MiniCalendar", () => {
it("renders current cycle day and phase", () => { it("renders current cycle day and phase", () => {
render(<MiniCalendar {...baseProps} />); 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(/Day 15/)).toBeInTheDocument();
expect(screen.getByText(/OVULATION/)).toBeInTheDocument(); expect(screen.getByText(/EARLY_LUTEAL/)).toBeInTheDocument();
}); });
it("renders compact day-of-week headers", () => { it("renders compact day-of-week headers", () => {
@@ -86,6 +87,8 @@ describe("MiniCalendar", () => {
}); });
describe("phase colors", () => { 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", () => { it("applies menstrual phase color (blue) to days 1-3", () => {
render(<MiniCalendar {...baseProps} />); render(<MiniCalendar {...baseProps} />);
@@ -95,37 +98,37 @@ describe("MiniCalendar", () => {
expect(day1).toHaveClass("bg-blue-100"); 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} />); 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 buttons = screen.getAllByRole("button");
const day5 = buttons.find((btn) => btn.textContent === "5"); const day5 = buttons.find((btn) => btn.textContent === "5");
expect(day5).toHaveClass("bg-green-100"); 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} />); 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 buttons = screen.getAllByRole("button");
const day15 = buttons.find((btn) => btn.textContent === "15"); const day13 = buttons.find((btn) => btn.textContent === "13");
expect(day15).toHaveClass("bg-purple-100"); 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} />); 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 buttons = screen.getAllByRole("button");
const day20 = buttons.find((btn) => btn.textContent === "20"); const day18 = buttons.find((btn) => btn.textContent === "18");
expect(day20).toHaveClass("bg-yellow-100"); 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} />); 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 buttons = screen.getAllByRole("button");
const day25 = buttons.find((btn) => btn.textContent === "25"); const day25 = buttons.find((btn) => btn.textContent === "25");
expect(day25).toHaveClass("bg-red-100"); expect(day25).toHaveClass("bg-red-100");

View File

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

View File

@@ -1,5 +1,5 @@
// ABOUTME: Unit tests for cycle phase calculation utilities. // 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 { describe, expect, it } from "vitest";
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle"; import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
@@ -25,29 +25,133 @@ describe("getCycleDay", () => {
}); });
describe("getPhase", () => { describe("getPhase", () => {
it("returns MENSTRUAL for days 1-3", () => { // Phase boundaries per spec (cycle-tracking.md):
expect(getPhase(1)).toBe("MENSTRUAL"); // MENSTRUAL: 1-3 (fixed)
expect(getPhase(3)).toBe("MENSTRUAL"); // 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, cycleLength)).toBe("MENSTRUAL");
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
});
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 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 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", () => {
// (31-6) to 31 = 25-31
expect(getPhase(25, cycleLength)).toBe("LATE_LUTEAL");
expect(getPhase(31, cycleLength)).toBe("LATE_LUTEAL");
});
}); });
it("returns FOLLICULAR for days 4-14", () => { describe("28-day cycle", () => {
expect(getPhase(4)).toBe("FOLLICULAR"); const cycleLength = 28;
expect(getPhase(14)).toBe("FOLLICULAR");
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");
});
}); });
it("returns OVULATION for days 15-16", () => { describe("35-day cycle", () => {
expect(getPhase(15)).toBe("OVULATION"); const cycleLength = 35;
expect(getPhase(16)).toBe("OVULATION");
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");
});
}); });
it("returns EARLY_LUTEAL for days 17-24", () => { describe("edge cases", () => {
expect(getPhase(17)).toBe("EARLY_LUTEAL"); it("defaults to LATE_LUTEAL for days beyond cycle length", () => {
expect(getPhase(24)).toBe("EARLY_LUTEAL"); expect(getPhase(32, 31)).toBe("LATE_LUTEAL");
}); expect(getPhase(40, 35)).toBe("LATE_LUTEAL");
});
it("returns LATE_LUTEAL for days 25-31", () => { it("handles minimum cycle length (21 days)", () => {
expect(getPhase(25)).toBe("LATE_LUTEAL"); // 21-day: FOLLICULAR 4-5, OVULATION 6-7, EARLY_LUTEAL 8-14, LATE_LUTEAL 15-21
expect(getPhase(31)).toBe("LATE_LUTEAL"); 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: 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"; 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[] = [ export const PHASE_CONFIGS: PhaseConfig[] = [
{ {
name: "MENSTRUAL", name: "MENSTRUAL",
@@ -12,21 +15,21 @@ export const PHASE_CONFIGS: PhaseConfig[] = [
}, },
{ {
name: "FOLLICULAR", name: "FOLLICULAR",
days: [4, 14], days: [4, 15],
weeklyLimit: 120, weeklyLimit: 120,
dailyAvg: 17, dailyAvg: 17,
trainingType: "Strength + rebounding", trainingType: "Strength + rebounding",
}, },
{ {
name: "OVULATION", name: "OVULATION",
days: [15, 16], days: [16, 17],
weeklyLimit: 80, weeklyLimit: 80,
dailyAvg: 40, dailyAvg: 40,
trainingType: "Peak performance", trainingType: "Peak performance",
}, },
{ {
name: "EARLY_LUTEAL", name: "EARLY_LUTEAL",
days: [17, 24], days: [18, 24],
weeklyLimit: 100, weeklyLimit: 100,
dailyAvg: 14, dailyAvg: 14,
trainingType: "Moderate training", 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( export function getCycleDay(
lastPeriodDate: Date, lastPeriodDate: Date,
cycleLength: number, cycleLength: number,
@@ -50,13 +73,15 @@ export function getCycleDay(
return (diffDays % cycleLength) + 1; return (diffDays % cycleLength) + 1;
} }
export function getPhase(cycleDay: number): CyclePhase { export function getPhase(cycleDay: number, cycleLength = 31): CyclePhase {
for (const config of PHASE_CONFIGS) { const boundaries = getPhaseBoundaries(cycleLength);
if (cycleDay >= config.days[0] && cycleDay <= config.days[1]) {
return config.name; 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"; return "LATE_LUTEAL";
} }

View File

@@ -36,27 +36,27 @@ describe("generateIcsFeed", () => {
describe("phase events", () => { describe("phase events", () => {
it("includes MENSTRUAL phase events", () => { it("includes MENSTRUAL phase events", () => {
const ics = generateIcsFeed(defaultOptions); const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🔵 MENSTRUAL"); expect(ics).toContain("🩸 MENSTRUAL");
}); });
it("includes FOLLICULAR phase events", () => { it("includes FOLLICULAR phase events", () => {
const ics = generateIcsFeed(defaultOptions); const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🟢 FOLLICULAR"); expect(ics).toContain("🌱 FOLLICULAR");
}); });
it("includes OVULATION phase events", () => { it("includes OVULATION phase events", () => {
const ics = generateIcsFeed(defaultOptions); const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🟣 OVULATION"); expect(ics).toContain("🌸 OVULATION");
}); });
it("includes EARLY_LUTEAL phase events", () => { it("includes EARLY_LUTEAL phase events", () => {
const ics = generateIcsFeed(defaultOptions); const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🟡 EARLY LUTEAL"); expect(ics).toContain("🌙 EARLY LUTEAL");
}); });
it("includes LATE_LUTEAL phase events", () => { it("includes LATE_LUTEAL phase events", () => {
const ics = generateIcsFeed(defaultOptions); const ics = generateIcsFeed(defaultOptions);
expect(ics).toContain("🔴 LATE LUTEAL"); expect(ics).toContain("🌑 LATE LUTEAL");
}); });
}); });
@@ -81,7 +81,7 @@ describe("generateIcsFeed", () => {
cycleLength: 31, cycleLength: 31,
}); });
// Should contain multiple cycles worth of events // 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); expect(menstrualCount).toBeGreaterThanOrEqual(3);
}); });
@@ -108,8 +108,8 @@ describe("generateIcsFeed", () => {
monthsAhead: 2, monthsAhead: 2,
}); });
// Should complete at least one full cycle // Should complete at least one full cycle
expect(ics).toContain("🔵 MENSTRUAL"); expect(ics).toContain("🩸 MENSTRUAL");
expect(ics).toContain("🔴 LATE LUTEAL"); expect(ics).toContain("🌑 LATE LUTEAL");
}); });
it("handles shorter 28-day cycle", () => { it("handles shorter 28-day cycle", () => {
@@ -119,8 +119,8 @@ describe("generateIcsFeed", () => {
monthsAhead: 2, monthsAhead: 2,
}); });
// Should still contain all phases // Should still contain all phases
expect(ics).toContain("🔵 MENSTRUAL"); expect(ics).toContain("🩸 MENSTRUAL");
expect(ics).toContain("🟢 FOLLICULAR"); expect(ics).toContain("🌱 FOLLICULAR");
}); });
it("handles longer 35-day cycle", () => { it("handles longer 35-day cycle", () => {
@@ -130,8 +130,8 @@ describe("generateIcsFeed", () => {
monthsAhead: 2, monthsAhead: 2,
}); });
// Should still contain all phases // Should still contain all phases
expect(ics).toContain("🔵 MENSTRUAL"); expect(ics).toContain("🩸 MENSTRUAL");
expect(ics).toContain("🟢 FOLLICULAR"); 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", () => { describe("prediction accuracy feedback", () => {
it("generates predicted event when period arrived early", () => { it("generates predicted event when period arrived early", () => {
const periodLogs: PeriodLog[] = [ const periodLogs: PeriodLog[] = [
@@ -218,7 +246,7 @@ describe("generateIcsFeed", () => {
}); });
// Should contain both actual and predicted menstrual events // 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("Original prediction");
expect(ics).toContain("period arrived 2 days early"); expect(ics).toContain("period arrived 2 days early");
}); });
@@ -242,7 +270,7 @@ describe("generateIcsFeed", () => {
}); });
// Should contain both actual and predicted menstrual events // 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("Original prediction");
expect(ics).toContain("period arrived 3 days late"); 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 type { PeriodLog } from "@/types";
import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle"; import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle";
// Phase emojis per calendar.md spec
const PHASE_EMOJIS: Record<string, string> = { const PHASE_EMOJIS: Record<string, string> = {
MENSTRUAL: "🔵", MENSTRUAL: "🩸",
FOLLICULAR: "🟢", FOLLICULAR: "🌱",
OVULATION: "🟣", OVULATION: "🌸",
EARLY_LUTEAL: "🟡", EARLY_LUTEAL: "🌙",
LATE_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 { interface IcsGeneratorOptions {
@@ -35,12 +45,13 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
const currentDate = new Date(lastPeriodDate); const currentDate = new Date(lastPeriodDate);
let currentPhase = getPhase( let currentPhase = getPhase(
getCycleDay(lastPeriodDate, cycleLength, currentDate), getCycleDay(lastPeriodDate, cycleLength, currentDate),
cycleLength,
); );
let phaseStartDate = new Date(currentDate); let phaseStartDate = new Date(currentDate);
while (currentDate <= endDate) { while (currentDate <= endDate) {
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate); const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate);
const phase = getPhase(cycleDay); const phase = getPhase(cycleDay, cycleLength);
// Add warning events // Add warning events
if (cycleDay === 22) { if (cycleDay === 22) {
@@ -107,7 +118,7 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
events.push({ events.push({
start: dateToArray(predicted), start: dateToArray(predicted),
end: dateToArray(predictedEnd), end: dateToArray(predictedEnd),
title: "🔵 MENSTRUAL (Predicted)", title: "🩸 MENSTRUAL (Predicted)",
description, description,
}); });
} }
@@ -127,12 +138,14 @@ function createPhaseEvent(
): EventAttributes { ): EventAttributes {
const config = PHASE_CONFIGS.find((c) => c.name === phase); const config = PHASE_CONFIGS.find((c) => c.name === phase);
const emoji = PHASE_EMOJIS[phase] || "📅"; const emoji = PHASE_EMOJIS[phase] || "📅";
const category = PHASE_CATEGORIES[phase];
return { return {
start: dateToArray(startDate), start: dateToArray(startDate),
end: dateToArray(endDate), end: dateToArray(endDate),
title: `${emoji} ${phase.replace("_", " ")}`, title: `${emoji} ${phase.replace("_", " ")}`,
description: config?.trainingType || "", description: config?.trainingType || "",
categories: category ? [category] : undefined,
}; };
} }