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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,8 +71,9 @@ describe("MonthView", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Jan 15 is "today" - aria-label includes date, cycle day, and phase
|
||||
// For 28-day cycle, day 15 is EARLY_LUTEAL (days 15-21)
|
||||
const todayCell = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
expect(todayCell).toHaveClass("ring-2", "ring-black");
|
||||
});
|
||||
@@ -99,40 +100,40 @@ describe("MonthView", () => {
|
||||
expect(day1).toHaveClass("bg-blue-100");
|
||||
});
|
||||
|
||||
it("applies follicular phase color to days 4-14", () => {
|
||||
it("applies follicular phase color to days 4-12", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 5 is FOLLICULAR (bg-green-100)
|
||||
// For 28-day cycle, FOLLICULAR is days 4-12
|
||||
const day5 = screen.getByRole("button", {
|
||||
name: /January 5, 2026 - Cycle day 5 - Follicular phase/i,
|
||||
});
|
||||
expect(day5).toHaveClass("bg-green-100");
|
||||
});
|
||||
|
||||
it("applies ovulation phase color to days 15-16", () => {
|
||||
it("applies ovulation phase color to days 13-14", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 15 is OVULATION (bg-purple-100)
|
||||
const day15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase/i,
|
||||
// For 28-day cycle, OVULATION is days 13-14
|
||||
const day13 = screen.getByRole("button", {
|
||||
name: /January 13, 2026 - Cycle day 13 - Ovulation phase/i,
|
||||
});
|
||||
expect(day15).toHaveClass("bg-purple-100");
|
||||
expect(day13).toHaveClass("bg-purple-100");
|
||||
});
|
||||
|
||||
it("applies early luteal phase color to days 17-24", () => {
|
||||
it("applies early luteal phase color to days 15-21", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
|
||||
const day20 = screen.getByRole("button", {
|
||||
name: /January 20, 2026 - Cycle day 20 - Early Luteal phase/i,
|
||||
// For 28-day cycle, EARLY_LUTEAL is days 15-21
|
||||
const day18 = screen.getByRole("button", {
|
||||
name: /January 18, 2026 - Cycle day 18 - Early Luteal phase/i,
|
||||
});
|
||||
expect(day20).toHaveClass("bg-yellow-100");
|
||||
expect(day18).toHaveClass("bg-yellow-100");
|
||||
});
|
||||
|
||||
it("applies late luteal phase color to days 25-31", () => {
|
||||
it("applies late luteal phase color to days 22-28", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Day 25 is LATE_LUTEAL (bg-red-100)
|
||||
// For 28-day cycle, LATE_LUTEAL is days 22-28
|
||||
const day25 = screen.getByRole("button", {
|
||||
name: /January 25, 2026 - Cycle day 25 - Late Luteal phase/i,
|
||||
});
|
||||
@@ -267,20 +268,22 @@ describe("MonthView", () => {
|
||||
});
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
// For 28-day cycle:
|
||||
// MENSTRUAL: 1-3, FOLLICULAR: 4-12, OVULATION: 13-14, EARLY_LUTEAL: 15-21, LATE_LUTEAL: 22-28
|
||||
it("moves focus to next day when pressing ArrowRight", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Focus on Jan 15 (today)
|
||||
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
||||
const jan15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
jan15.focus();
|
||||
|
||||
// Press ArrowRight to move to Jan 16
|
||||
// Press ArrowRight to move to Jan 16 - day 16 is EARLY_LUTEAL
|
||||
fireEvent.keyDown(jan15, { key: "ArrowRight" });
|
||||
|
||||
const jan16 = screen.getByRole("button", {
|
||||
name: /January 16, 2026 - Cycle day 16 - Ovulation phase$/i,
|
||||
name: /January 16, 2026 - Cycle day 16 - Early Luteal phase$/i,
|
||||
});
|
||||
expect(document.activeElement).toBe(jan16);
|
||||
});
|
||||
@@ -288,17 +291,17 @@ describe("MonthView", () => {
|
||||
it("moves focus to previous day when pressing ArrowLeft", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Focus on Jan 15 (today)
|
||||
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
||||
const jan15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
jan15.focus();
|
||||
|
||||
// Press ArrowLeft to move to Jan 14
|
||||
// Press ArrowLeft to move to Jan 14 - day 14 is OVULATION
|
||||
fireEvent.keyDown(jan15, { key: "ArrowLeft" });
|
||||
|
||||
const jan14 = screen.getByRole("button", {
|
||||
name: /January 14, 2026 - Cycle day 14 - Follicular phase$/i,
|
||||
name: /January 14, 2026 - Cycle day 14 - Ovulation phase$/i,
|
||||
});
|
||||
expect(document.activeElement).toBe(jan14);
|
||||
});
|
||||
@@ -306,17 +309,17 @@ describe("MonthView", () => {
|
||||
it("moves focus to same day next week when pressing ArrowDown", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Focus on Jan 15 (today)
|
||||
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
||||
const jan15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
jan15.focus();
|
||||
|
||||
// Press ArrowDown to move to Jan 22 (7 days later)
|
||||
// Press ArrowDown to move to Jan 22 (7 days later) - day 22 is LATE_LUTEAL
|
||||
fireEvent.keyDown(jan15, { key: "ArrowDown" });
|
||||
|
||||
const jan22 = screen.getByRole("button", {
|
||||
name: /January 22, 2026 - Cycle day 22 - Early Luteal phase$/i,
|
||||
name: /January 22, 2026 - Cycle day 22 - Late Luteal phase$/i,
|
||||
});
|
||||
expect(document.activeElement).toBe(jan22);
|
||||
});
|
||||
@@ -324,13 +327,13 @@ describe("MonthView", () => {
|
||||
it("moves focus to same day previous week when pressing ArrowUp", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Focus on Jan 15 (today)
|
||||
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
||||
const jan15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
jan15.focus();
|
||||
|
||||
// Press ArrowUp to move to Jan 8 (7 days earlier)
|
||||
// Press ArrowUp to move to Jan 8 (7 days earlier) - day 8 is FOLLICULAR
|
||||
fireEvent.keyDown(jan15, { key: "ArrowUp" });
|
||||
|
||||
const jan8 = screen.getByRole("button", {
|
||||
@@ -375,9 +378,9 @@ describe("MonthView", () => {
|
||||
it("wraps focus at row boundaries for Home and End keys", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Focus on Jan 15
|
||||
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
|
||||
const jan15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
jan15.focus();
|
||||
|
||||
@@ -393,9 +396,9 @@ describe("MonthView", () => {
|
||||
it("moves focus to last day when pressing End key", () => {
|
||||
render(<MonthView {...baseProps} />);
|
||||
|
||||
// Focus on Jan 15
|
||||
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
|
||||
const jan15 = screen.getByRole("button", {
|
||||
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
|
||||
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
||||
});
|
||||
jan15.focus();
|
||||
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -34,9 +34,10 @@ describe("MiniCalendar", () => {
|
||||
it("renders current cycle day and phase", () => {
|
||||
render(<MiniCalendar {...baseProps} />);
|
||||
|
||||
// Jan 15, 2026 with lastPeriod Jan 1 = Day 15 (OVULATION)
|
||||
// Jan 15, 2026 with lastPeriod Jan 1 = Day 15
|
||||
// For 28-day cycle: EARLY_LUTEAL starts at day 15 (28-13=15)
|
||||
expect(screen.getByText(/Day 15/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/OVULATION/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/EARLY_LUTEAL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders compact day-of-week headers", () => {
|
||||
@@ -86,6 +87,8 @@ describe("MiniCalendar", () => {
|
||||
});
|
||||
|
||||
describe("phase colors", () => {
|
||||
// For 28-day cycle:
|
||||
// MENSTRUAL: 1-3, FOLLICULAR: 4-12, OVULATION: 13-14, EARLY_LUTEAL: 15-21, LATE_LUTEAL: 22-28
|
||||
it("applies menstrual phase color (blue) to days 1-3", () => {
|
||||
render(<MiniCalendar {...baseProps} />);
|
||||
|
||||
@@ -95,37 +98,37 @@ describe("MiniCalendar", () => {
|
||||
expect(day1).toHaveClass("bg-blue-100");
|
||||
});
|
||||
|
||||
it("applies follicular phase color (green) to days 4-14", () => {
|
||||
it("applies follicular phase color (green) to days 4-12", () => {
|
||||
render(<MiniCalendar {...baseProps} />);
|
||||
|
||||
// Day 5 is FOLLICULAR (bg-green-100)
|
||||
// Day 5 is FOLLICULAR (bg-green-100) for 28-day cycle
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const day5 = buttons.find((btn) => btn.textContent === "5");
|
||||
expect(day5).toHaveClass("bg-green-100");
|
||||
});
|
||||
|
||||
it("applies ovulation phase color (purple) to days 15-16", () => {
|
||||
it("applies ovulation phase color (purple) to days 13-14", () => {
|
||||
render(<MiniCalendar {...baseProps} />);
|
||||
|
||||
// Day 15 is OVULATION (bg-purple-100)
|
||||
// Day 13 is OVULATION (bg-purple-100) for 28-day cycle
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const day15 = buttons.find((btn) => btn.textContent === "15");
|
||||
expect(day15).toHaveClass("bg-purple-100");
|
||||
const day13 = buttons.find((btn) => btn.textContent === "13");
|
||||
expect(day13).toHaveClass("bg-purple-100");
|
||||
});
|
||||
|
||||
it("applies early luteal phase color (yellow) to days 17-24", () => {
|
||||
it("applies early luteal phase color (yellow) to days 15-21", () => {
|
||||
render(<MiniCalendar {...baseProps} />);
|
||||
|
||||
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
|
||||
// Day 18 is EARLY_LUTEAL (bg-yellow-100) for 28-day cycle
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const day20 = buttons.find((btn) => btn.textContent === "20");
|
||||
expect(day20).toHaveClass("bg-yellow-100");
|
||||
const day18 = buttons.find((btn) => btn.textContent === "18");
|
||||
expect(day18).toHaveClass("bg-yellow-100");
|
||||
});
|
||||
|
||||
it("applies late luteal phase color (red) to days 25-31", () => {
|
||||
it("applies late luteal phase color (red) to days 22-28", () => {
|
||||
render(<MiniCalendar {...baseProps} />);
|
||||
|
||||
// Day 25 is LATE_LUTEAL (bg-red-100)
|
||||
// Day 25 is LATE_LUTEAL (bg-red-100) for 28-day cycle
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const day25 = buttons.find((btn) => btn.textContent === "25");
|
||||
expect(day25).toHaveClass("bg-red-100");
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ABOUTME: Unit tests for cycle phase calculation utilities.
|
||||
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions.
|
||||
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions with variable cycle lengths.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
|
||||
@@ -25,29 +25,133 @@ describe("getCycleDay", () => {
|
||||
});
|
||||
|
||||
describe("getPhase", () => {
|
||||
// Phase boundaries per spec (cycle-tracking.md):
|
||||
// MENSTRUAL: 1-3 (fixed)
|
||||
// FOLLICULAR: 4 to (cycleLength - 16)
|
||||
// OVULATION: (cycleLength - 15) to (cycleLength - 14)
|
||||
// EARLY_LUTEAL: (cycleLength - 13) to (cycleLength - 7)
|
||||
// LATE_LUTEAL: (cycleLength - 6) to cycleLength
|
||||
|
||||
describe("31-day cycle", () => {
|
||||
const cycleLength = 31;
|
||||
|
||||
it("returns MENSTRUAL for days 1-3", () => {
|
||||
expect(getPhase(1)).toBe("MENSTRUAL");
|
||||
expect(getPhase(3)).toBe("MENSTRUAL");
|
||||
expect(getPhase(1, cycleLength)).toBe("MENSTRUAL");
|
||||
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
|
||||
});
|
||||
|
||||
it("returns FOLLICULAR for days 4-14", () => {
|
||||
expect(getPhase(4)).toBe("FOLLICULAR");
|
||||
expect(getPhase(14)).toBe("FOLLICULAR");
|
||||
it("returns FOLLICULAR for days 4-15", () => {
|
||||
// 4 to (31-16) = 4-15
|
||||
expect(getPhase(4, cycleLength)).toBe("FOLLICULAR");
|
||||
expect(getPhase(15, cycleLength)).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("returns OVULATION for days 15-16", () => {
|
||||
expect(getPhase(15)).toBe("OVULATION");
|
||||
expect(getPhase(16)).toBe("OVULATION");
|
||||
it("returns OVULATION for days 16-17", () => {
|
||||
// (31-15) to (31-14) = 16-17
|
||||
expect(getPhase(16, cycleLength)).toBe("OVULATION");
|
||||
expect(getPhase(17, cycleLength)).toBe("OVULATION");
|
||||
});
|
||||
|
||||
it("returns EARLY_LUTEAL for days 17-24", () => {
|
||||
expect(getPhase(17)).toBe("EARLY_LUTEAL");
|
||||
expect(getPhase(24)).toBe("EARLY_LUTEAL");
|
||||
it("returns EARLY_LUTEAL for days 18-24", () => {
|
||||
// (31-13) to (31-7) = 18-24
|
||||
expect(getPhase(18, cycleLength)).toBe("EARLY_LUTEAL");
|
||||
expect(getPhase(24, cycleLength)).toBe("EARLY_LUTEAL");
|
||||
});
|
||||
|
||||
it("returns LATE_LUTEAL for days 25-31", () => {
|
||||
expect(getPhase(25)).toBe("LATE_LUTEAL");
|
||||
expect(getPhase(31)).toBe("LATE_LUTEAL");
|
||||
// (31-6) to 31 = 25-31
|
||||
expect(getPhase(25, cycleLength)).toBe("LATE_LUTEAL");
|
||||
expect(getPhase(31, cycleLength)).toBe("LATE_LUTEAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("28-day cycle", () => {
|
||||
const cycleLength = 28;
|
||||
|
||||
it("returns MENSTRUAL for days 1-3", () => {
|
||||
expect(getPhase(1, cycleLength)).toBe("MENSTRUAL");
|
||||
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
|
||||
});
|
||||
|
||||
it("returns FOLLICULAR for days 4-12", () => {
|
||||
// 4 to (28-16) = 4-12
|
||||
expect(getPhase(4, cycleLength)).toBe("FOLLICULAR");
|
||||
expect(getPhase(12, cycleLength)).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("returns OVULATION for days 13-14", () => {
|
||||
// (28-15) to (28-14) = 13-14
|
||||
expect(getPhase(13, cycleLength)).toBe("OVULATION");
|
||||
expect(getPhase(14, cycleLength)).toBe("OVULATION");
|
||||
});
|
||||
|
||||
it("returns EARLY_LUTEAL for days 15-21", () => {
|
||||
// (28-13) to (28-7) = 15-21
|
||||
expect(getPhase(15, cycleLength)).toBe("EARLY_LUTEAL");
|
||||
expect(getPhase(21, cycleLength)).toBe("EARLY_LUTEAL");
|
||||
});
|
||||
|
||||
it("returns LATE_LUTEAL for days 22-28", () => {
|
||||
// (28-6) to 28 = 22-28
|
||||
expect(getPhase(22, cycleLength)).toBe("LATE_LUTEAL");
|
||||
expect(getPhase(28, cycleLength)).toBe("LATE_LUTEAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("35-day cycle", () => {
|
||||
const cycleLength = 35;
|
||||
|
||||
it("returns MENSTRUAL for days 1-3", () => {
|
||||
expect(getPhase(1, cycleLength)).toBe("MENSTRUAL");
|
||||
expect(getPhase(3, cycleLength)).toBe("MENSTRUAL");
|
||||
});
|
||||
|
||||
it("returns FOLLICULAR for days 4-19", () => {
|
||||
// 4 to (35-16) = 4-19
|
||||
expect(getPhase(4, cycleLength)).toBe("FOLLICULAR");
|
||||
expect(getPhase(19, cycleLength)).toBe("FOLLICULAR");
|
||||
});
|
||||
|
||||
it("returns OVULATION for days 20-21", () => {
|
||||
// (35-15) to (35-14) = 20-21
|
||||
expect(getPhase(20, cycleLength)).toBe("OVULATION");
|
||||
expect(getPhase(21, cycleLength)).toBe("OVULATION");
|
||||
});
|
||||
|
||||
it("returns EARLY_LUTEAL for days 22-28", () => {
|
||||
// (35-13) to (35-7) = 22-28
|
||||
expect(getPhase(22, cycleLength)).toBe("EARLY_LUTEAL");
|
||||
expect(getPhase(28, cycleLength)).toBe("EARLY_LUTEAL");
|
||||
});
|
||||
|
||||
it("returns LATE_LUTEAL for days 29-35", () => {
|
||||
// (35-6) to 35 = 29-35
|
||||
expect(getPhase(29, cycleLength)).toBe("LATE_LUTEAL");
|
||||
expect(getPhase(35, cycleLength)).toBe("LATE_LUTEAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("defaults to LATE_LUTEAL for days beyond cycle length", () => {
|
||||
expect(getPhase(32, 31)).toBe("LATE_LUTEAL");
|
||||
expect(getPhase(40, 35)).toBe("LATE_LUTEAL");
|
||||
});
|
||||
|
||||
it("handles minimum cycle length (21 days)", () => {
|
||||
// 21-day: FOLLICULAR 4-5, OVULATION 6-7, EARLY_LUTEAL 8-14, LATE_LUTEAL 15-21
|
||||
expect(getPhase(5, 21)).toBe("FOLLICULAR"); // 4 to (21-16)=5
|
||||
expect(getPhase(6, 21)).toBe("OVULATION"); // (21-15)=6 to (21-14)=7
|
||||
expect(getPhase(8, 21)).toBe("EARLY_LUTEAL"); // (21-13)=8 to (21-7)=14
|
||||
expect(getPhase(15, 21)).toBe("LATE_LUTEAL"); // (21-6)=15 to 21
|
||||
});
|
||||
|
||||
it("handles maximum cycle length (45 days)", () => {
|
||||
// 45-day: FOLLICULAR 4-29, OVULATION 30-31, EARLY_LUTEAL 32-38, LATE_LUTEAL 39-45
|
||||
expect(getPhase(29, 45)).toBe("FOLLICULAR"); // 4 to (45-16)=29
|
||||
expect(getPhase(30, 45)).toBe("OVULATION"); // (45-15)=30 to (45-14)=31
|
||||
expect(getPhase(32, 45)).toBe("EARLY_LUTEAL"); // (45-13)=32 to (45-7)=38
|
||||
expect(getPhase(39, 45)).toBe("LATE_LUTEAL"); // (45-6)=39 to 45
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -5,12 +5,22 @@ import { createEvents, type EventAttributes } from "ics";
|
||||
import type { PeriodLog } from "@/types";
|
||||
import { getCycleDay, getPhase, PHASE_CONFIGS } from "./cycle";
|
||||
|
||||
// Phase emojis per calendar.md spec
|
||||
const PHASE_EMOJIS: Record<string, string> = {
|
||||
MENSTRUAL: "🔵",
|
||||
FOLLICULAR: "🟢",
|
||||
OVULATION: "🟣",
|
||||
EARLY_LUTEAL: "🟡",
|
||||
LATE_LUTEAL: "🔴",
|
||||
MENSTRUAL: "🩸",
|
||||
FOLLICULAR: "🌱",
|
||||
OVULATION: "🌸",
|
||||
EARLY_LUTEAL: "🌙",
|
||||
LATE_LUTEAL: "🌑",
|
||||
};
|
||||
|
||||
// Phase color categories per calendar.md spec for calendar app color coding
|
||||
const PHASE_CATEGORIES: Record<string, string> = {
|
||||
MENSTRUAL: "Red",
|
||||
FOLLICULAR: "Green",
|
||||
OVULATION: "Pink",
|
||||
EARLY_LUTEAL: "Yellow",
|
||||
LATE_LUTEAL: "Orange",
|
||||
};
|
||||
|
||||
interface IcsGeneratorOptions {
|
||||
@@ -35,12 +45,13 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
|
||||
const currentDate = new Date(lastPeriodDate);
|
||||
let currentPhase = getPhase(
|
||||
getCycleDay(lastPeriodDate, cycleLength, currentDate),
|
||||
cycleLength,
|
||||
);
|
||||
let phaseStartDate = new Date(currentDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, currentDate);
|
||||
const phase = getPhase(cycleDay);
|
||||
const phase = getPhase(cycleDay, cycleLength);
|
||||
|
||||
// Add warning events
|
||||
if (cycleDay === 22) {
|
||||
@@ -107,7 +118,7 @@ export function generateIcsFeed(options: IcsGeneratorOptions): string {
|
||||
events.push({
|
||||
start: dateToArray(predicted),
|
||||
end: dateToArray(predictedEnd),
|
||||
title: "🔵 MENSTRUAL (Predicted)",
|
||||
title: "🩸 MENSTRUAL (Predicted)",
|
||||
description,
|
||||
});
|
||||
}
|
||||
@@ -127,12 +138,14 @@ function createPhaseEvent(
|
||||
): EventAttributes {
|
||||
const config = PHASE_CONFIGS.find((c) => c.name === phase);
|
||||
const emoji = PHASE_EMOJIS[phase] || "📅";
|
||||
const category = PHASE_CATEGORIES[phase];
|
||||
|
||||
return {
|
||||
start: dateToArray(startDate),
|
||||
end: dateToArray(endDate),
|
||||
title: `${emoji} ${phase.replace("_", " ")}`,
|
||||
description: config?.trainingType || "",
|
||||
categories: category ? [category] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user