Files
phaseflow/src/lib/cycle.test.ts
Petru Paler 6cd0c06396
All checks were successful
Deploy / deploy (push) Successful in 2m38s
Fix Garmin intensity minutes and add user-configurable phase goals
- Apply 2x multiplier for vigorous intensity minutes (matches Garmin)
- Use calendar week (Mon-Sun) instead of trailing 7 days for intensity
- Add HRV yesterday fallback when today's data returns empty
- Add user-configurable phase intensity goals with new defaults:
  - Menstrual: 75, Follicular: 150, Ovulation: 100
  - Early Luteal: 120, Late Luteal: 50
- Update garmin-sync and today routes to use user-specific phase limits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:18:20 +00:00

168 lines
6.0 KiB
TypeScript

// ABOUTME: Unit tests for cycle phase calculation utilities.
// ABOUTME: Tests getCycleDay, getPhase, and phase limit functions with variable cycle lengths.
import { describe, expect, it } from "vitest";
import { getCycleDay, getPhase, getPhaseLimit } from "./cycle";
describe("getCycleDay", () => {
it("returns 1 on the first day of the cycle", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-01-01");
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
});
it("returns correct day within cycle", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-01-15");
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(15);
});
it("wraps around after cycle length", () => {
const lastPeriod = new Date("2025-01-01");
const currentDate = new Date("2025-02-01"); // 31 days later
expect(getCycleDay(lastPeriod, 31, currentDate)).toBe(1);
});
});
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, 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");
});
});
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
});
});
});
describe("getPhaseLimit", () => {
it("returns correct weekly limits for each phase", () => {
// Default intensity goals (can be overridden per user)
expect(getPhaseLimit("MENSTRUAL")).toBe(75);
expect(getPhaseLimit("FOLLICULAR")).toBe(150);
expect(getPhaseLimit("OVULATION")).toBe(100);
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(120);
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
});
});