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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user