Implement GET /api/cycle/current endpoint (P1.3)

Add endpoint returning current cycle day, phase, phase configuration,
and days until next phase. Uses withAuth middleware for authentication.

Response shape:
- cycleDay: current day in menstrual cycle (1-31)
- phase: current phase (MENSTRUAL, FOLLICULAR, OVULATION, EARLY_LUTEAL, LATE_LUTEAL)
- phaseConfig: full configuration including weeklyLimit, trainingType
- daysUntilNextPhase: days remaining in current phase
- cycleLength: user's configured cycle length

Includes 10 tests covering:
- Authentication (401 when not authenticated)
- Validation (400 when no lastPeriodDate)
- All five cycle phases
- Cycle rollover handling
- Custom cycle lengths

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 18:58:04 +00:00
parent 62ad2e3d1a
commit b6285e3c01
3 changed files with 304 additions and 8 deletions

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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,
});
});