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