From a977934c231016bee1baad0d9d8cf60d1d4b34af Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sun, 11 Jan 2026 22:39:09 +0000 Subject: [PATCH] Fix critical bug: cycle phase boundaries now scale with cycle length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- IMPLEMENTATION_PLAN.md | 17 ++- src/app/api/cron/garmin-sync/route.ts | 2 +- src/app/api/cycle/current/route.test.ts | 20 +-- src/app/api/cycle/current/route.ts | 43 +++--- src/app/api/cycle/period/route.ts | 2 +- src/app/api/today/route.test.ts | 6 +- src/app/api/today/route.ts | 23 +-- src/components/calendar/month-view.test.tsx | 71 ++++----- src/components/calendar/month-view.tsx | 2 +- .../dashboard/mini-calendar.test.tsx | 31 ++-- src/components/dashboard/mini-calendar.tsx | 4 +- src/lib/cycle.test.ts | 138 +++++++++++++++--- src/lib/cycle.ts | 43 ++++-- src/lib/ics.test.ts | 56 +++++-- src/lib/ics.ts | 27 +++- 15 files changed, 337 insertions(+), 148 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9e9e81d..0bd8eba 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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) diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index 8694a92..661c25d 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -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); diff --git a/src/app/api/cycle/current/route.test.ts b/src/app/api/cycle/current/route.test.ts index 99ac153..44a95d2 100644 --- a/src/app/api/cycle/current/route.test.ts +++ b/src/app/api/cycle/current/route.test.ts @@ -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 () => { diff --git a/src/app/api/cycle/current/route.ts b/src/app/api/cycle/current/route.ts index 8618212..1c47aa4 100644 --- a/src/app/api/cycle/current/route.ts +++ b/src/app/api/cycle/current/route.ts @@ -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); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 2f75030..38f8090 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -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; diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index 86b780b..9a21550 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -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); }); }); diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index b37e0fe..17567f6 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -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 diff --git a/src/components/calendar/month-view.test.tsx b/src/components/calendar/month-view.test.tsx index ab1d217..c161fcc 100644 --- a/src/components/calendar/month-view.test.tsx +++ b/src/components/calendar/month-view.test.tsx @@ -71,8 +71,9 @@ describe("MonthView", () => { render(); // 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(); - // 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(); - // 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(); - // 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(); - // 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(); - // 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(); - // 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(); - // 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(); - // 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(); - // 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(); - // 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(); diff --git a/src/components/calendar/month-view.tsx b/src/components/calendar/month-view.tsx index e8f8038..5c4d4bb 100644 --- a/src/components/calendar/month-view.tsx +++ b/src/components/calendar/month-view.tsx @@ -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() && diff --git a/src/components/dashboard/mini-calendar.test.tsx b/src/components/dashboard/mini-calendar.test.tsx index c861033..ee97e43 100644 --- a/src/components/dashboard/mini-calendar.test.tsx +++ b/src/components/dashboard/mini-calendar.test.tsx @@ -34,9 +34,10 @@ describe("MiniCalendar", () => { it("renders current cycle day and phase", () => { render(); - // 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(); @@ -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(); - // 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(); - // 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(); - // 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(); - // 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"); diff --git a/src/components/dashboard/mini-calendar.tsx b/src/components/dashboard/mini-calendar.tsx index 2d47a95..9ece630 100644 --- a/src/components/dashboard/mini-calendar.tsx +++ b/src/components/dashboard/mini-calendar.tsx @@ -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() && diff --git a/src/lib/cycle.test.ts b/src/lib/cycle.test.ts index 9474c81..2aaa542 100644 --- a/src/lib/cycle.test.ts +++ b/src/lib/cycle.test.ts @@ -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", () => { - it("returns MENSTRUAL for days 1-3", () => { - expect(getPhase(1)).toBe("MENSTRUAL"); - expect(getPhase(3)).toBe("MENSTRUAL"); + // 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, 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", () => { - expect(getPhase(4)).toBe("FOLLICULAR"); - expect(getPhase(14)).toBe("FOLLICULAR"); + 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"); + }); }); - it("returns OVULATION for days 15-16", () => { - expect(getPhase(15)).toBe("OVULATION"); - expect(getPhase(16)).toBe("OVULATION"); + 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"); + }); }); - it("returns EARLY_LUTEAL for days 17-24", () => { - expect(getPhase(17)).toBe("EARLY_LUTEAL"); - expect(getPhase(24)).toBe("EARLY_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("returns LATE_LUTEAL for days 25-31", () => { - expect(getPhase(25)).toBe("LATE_LUTEAL"); - expect(getPhase(31)).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 + }); }); }); diff --git a/src/lib/cycle.ts b/src/lib/cycle.ts index 6275e2c..71bdc8d 100644 --- a/src/lib/cycle.ts +++ b/src/lib/cycle.ts @@ -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"; } diff --git a/src/lib/ics.test.ts b/src/lib/ics.test.ts index b547f01..e969958 100644 --- a/src/lib/ics.test.ts +++ b/src/lib/ics.test.ts @@ -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"); }); diff --git a/src/lib/ics.ts b/src/lib/ics.ts index 70067e8..7e88bf8 100644 --- a/src/lib/ics.ts +++ b/src/lib/ics.ts @@ -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 = { - 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 = { + 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, }; }