diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5dbe843..e559c5d 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -28,7 +28,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` | | PATCH /api/user | 501 | Returns Not Implemented | | POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) | -| GET /api/cycle/current | 501 | Returns Not Implemented | +| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) | | GET /api/today | 501 | Returns Not Implemented | | POST /api/overrides | 501 | Returns Not Implemented | | DELETE /api/overrides | 501 | Returns Not Implemented | @@ -72,6 +72,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/middleware.test.ts` | **EXISTS** - 12 tests (page protection, public routes, static assets) | | `src/app/api/user/route.test.ts` | **EXISTS** - 4 tests (GET profile, auth, sensitive field exclusion) | | `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) | +| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) | | `src/lib/nutrition.test.ts` | **MISSING** | | `src/lib/email.test.ts` | **MISSING** | | `src/lib/ics.test.ts` | **MISSING** | @@ -164,12 +165,14 @@ Minimum viable product - app can be used for daily decisions. - **Why:** Cycle tracking is the foundation of all recommendations - **Depends On:** P0.1, P0.2 -### P1.3: GET /api/cycle/current Implementation -- [ ] Return current cycle day, phase, and phase config +### P1.3: GET /api/cycle/current Implementation ✅ COMPLETE +- [x] Return current cycle day, phase, and phase config - **Files:** - - `src/app/api/cycle/current/route.ts` - Implement GET using cycle.ts utilities + - `src/app/api/cycle/current/route.ts` - Implemented GET using cycle.ts utilities with `withAuth()` wrapper - **Tests:** - - `src/app/api/cycle/current/route.test.ts` - Test phase calculation, config response + - `src/app/api/cycle/current/route.test.ts` - 10 tests covering auth, validation, all phases, cycle rollover, custom cycle lengths +- **Response Shape:** + - `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength` - **Why:** Dashboard needs this for display - **Depends On:** P0.1, P0.2, P1.2 @@ -483,6 +486,7 @@ P2.14 Mini calendar ### API Routes - [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4) - [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2) +- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3) --- diff --git a/src/app/api/cycle/current/route.test.ts b/src/app/api/cycle/current/route.test.ts new file mode 100644 index 0000000..99ac153 --- /dev/null +++ b/src/app/api/cycle/current/route.test.ts @@ -0,0 +1,233 @@ +// ABOUTME: Unit tests for current cycle phase API route. +// ABOUTME: Tests GET /api/cycle/current for cycle day and phase information. +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/types"; + +// Module-level variable to control mock user in tests +let currentMockUser: User | null = null; + +// Mock PocketBase client for database operations +vi.mock("@/lib/pocketbase", () => ({ + createPocketBaseClient: vi.fn(() => ({})), + loadAuthFromCookies: vi.fn(), + isAuthenticated: vi.fn(() => currentMockUser !== null), + getCurrentUser: vi.fn(() => currentMockUser), +})); + +// Mock the auth-middleware module +vi.mock("@/lib/auth-middleware", () => ({ + withAuth: vi.fn((handler) => { + return async (request: NextRequest) => { + if (!currentMockUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return handler(request, currentMockUser); + }; + }), +})); + +import { GET } from "./route"; + +describe("GET /api/cycle/current", () => { + const createMockUser = (overrides: Partial = {}): User => ({ + id: "user123", + email: "test@example.com", + garminConnected: false, + garminOauth1Token: "", + garminOauth2Token: "", + garminTokenExpiresAt: new Date("2025-06-01"), + calendarToken: "cal-secret-token", + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + notificationTime: "07:00", + timezone: "America/New_York", + activeOverrides: [], + created: new Date("2024-01-01"), + updated: new Date("2025-01-10"), + ...overrides, + }); + + const mockRequest = {} as NextRequest; + + beforeEach(() => { + vi.clearAllMocks(); + currentMockUser = null; + // Mock current date to 2025-01-10 for predictable testing + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-10T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 401 when not authenticated", async () => { + currentMockUser = null; + + const response = await GET(mockRequest); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 400 when user has no lastPeriodDate", async () => { + currentMockUser = createMockUser({ + lastPeriodDate: null as unknown as Date, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("lastPeriodDate"); + }); + + it("returns current cycle day and phase information", async () => { + // lastPeriodDate: 2025-01-01, current: 2025-01-10 + // That's 9 days difference, so cycle day = 10 + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.cycleDay).toBe(10); + expect(body.phase).toBe("FOLLICULAR"); + }); + + it("returns complete phase configuration", async () => { + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.phaseConfig).toBeDefined(); + 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]); + 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 + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.daysUntilNextPhase).toBe(5); + }); + + it("returns correct data for MENSTRUAL phase", async () => { + // Set lastPeriodDate to 2025-01-08 so cycle day = 3 on 2025-01-10 + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2025-01-08"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.cycleDay).toBe(3); + expect(body.phase).toBe("MENSTRUAL"); + expect(body.phaseConfig.weeklyLimit).toBe(30); + expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR + }); + + 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) + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2024-12-27"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.cycleDay).toBe(15); + expect(body.phase).toBe("OVULATION"); + expect(body.phaseConfig.weeklyLimit).toBe(80); + expect(body.daysUntilNextPhase).toBe(2); // Day 17 is EARLY_LUTEAL + }); + + it("returns correct data for LATE_LUTEAL phase", async () => { + // Set lastPeriodDate so cycle day = 28 (in LATE_LUTEAL days 25-31) + // If current is 2025-01-10, need lastPeriodDate = 2024-12-14 (27 days ago) + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2024-12-14"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.cycleDay).toBe(28); + expect(body.phase).toBe("LATE_LUTEAL"); + expect(body.phaseConfig.weeklyLimit).toBe(50); + // Days until next cycle starts (new MENSTRUAL): 31 - 28 + 1 = 4 + expect(body.daysUntilNextPhase).toBe(4); + }); + + it("handles cycle rollover correctly", async () => { + // Set lastPeriodDate so we're past day 31 with cycleLength 31 + // 40 days ago: (40 % 31) + 1 = 10 + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2024-12-01"), + cycleLength: 31, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + // 40 days: (40 % 31) + 1 = 10 + expect(body.cycleDay).toBe(10); + expect(body.phase).toBe("FOLLICULAR"); + }); + + it("works with custom cycle length", async () => { + // With 28 day cycle, same offset gives different results + // lastPeriodDate: 2025-01-01, current: 2025-01-10 + // 9 days difference, so cycle day = 10 (within 28) + currentMockUser = createMockUser({ + lastPeriodDate: new Date("2025-01-01"), + cycleLength: 28, + }); + + const response = await GET(mockRequest); + + expect(response.status).toBe(200); + const body = await response.json(); + + expect(body.cycleDay).toBe(10); + expect(body.cycleLength).toBe(28); + }); +}); diff --git a/src/app/api/cycle/current/route.ts b/src/app/api/cycle/current/route.ts index f703e9b..8618212 100644 --- a/src/app/api/cycle/current/route.ts +++ b/src/app/api/cycle/current/route.ts @@ -2,7 +2,66 @@ // ABOUTME: Returns current cycle day, phase, and phase limits. import { NextResponse } from "next/server"; -export async function GET() { - // TODO: Implement current cycle info - return NextResponse.json({ message: "Not implemented" }, { status: 501 }); +import { withAuth } from "@/lib/auth-middleware"; +import { + getCycleDay, + getPhase, + getPhaseConfig, + PHASE_CONFIGS, +} from "@/lib/cycle"; + +/** + * 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); + + // 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; } + +export const GET = withAuth(async (_request, user) => { + // Validate user has required cycle data + if (!user.lastPeriodDate) { + return NextResponse.json( + { + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }, + { status: 400 }, + ); + } + + // Calculate current cycle position + const cycleDay = getCycleDay( + user.lastPeriodDate, + user.cycleLength, + new Date(), + ); + const phase = getPhase(cycleDay); + const phaseConfig = getPhaseConfig(phase); + const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength); + + return NextResponse.json({ + cycleDay, + phase, + phaseConfig, + daysUntilNextPhase, + cycleLength: user.cycleLength, + }); +});