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>
422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
// ABOUTME: Unit tests for the MonthView component.
|
|
// ABOUTME: Tests calendar grid rendering, phase display, navigation, and day cell integration.
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { MonthView } from "./month-view";
|
|
|
|
describe("MonthView", () => {
|
|
// Fixed date for consistent testing: January 2026
|
|
const baseProps = {
|
|
year: 2026,
|
|
month: 0, // January (0-indexed)
|
|
lastPeriodDate: new Date("2026-01-01"),
|
|
cycleLength: 28,
|
|
onMonthChange: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Mock Date.now to return Jan 15, 2026
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-15"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("header rendering", () => {
|
|
it("renders the month and year in header", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(screen.getByText("January 2026")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders day-of-week headers", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(screen.getByText("Sun")).toBeInTheDocument();
|
|
expect(screen.getByText("Mon")).toBeInTheDocument();
|
|
expect(screen.getByText("Tue")).toBeInTheDocument();
|
|
expect(screen.getByText("Wed")).toBeInTheDocument();
|
|
expect(screen.getByText("Thu")).toBeInTheDocument();
|
|
expect(screen.getByText("Fri")).toBeInTheDocument();
|
|
expect(screen.getByText("Sat")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("calendar grid", () => {
|
|
it("renders all days of the month", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// January 2026 has 31 days
|
|
for (let day = 1; day <= 31; day++) {
|
|
expect(screen.getByText(day.toString())).toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it("renders day cells with cycle day information", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// With lastPeriodDate = Jan 1 and cycleLength = 28:
|
|
// Day 1 appears twice (Jan 1 = Day 1, Jan 29 = Day 1 due to cycle rollover)
|
|
// Day 15 appears once (Jan 15)
|
|
const day1Elements = screen.getAllByText("Day 1");
|
|
expect(day1Elements.length).toBeGreaterThanOrEqual(1);
|
|
expect(screen.getByText("Day 15")).toBeInTheDocument();
|
|
});
|
|
|
|
it("highlights today's date", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Jan 15 is "today" - aria-label includes date, cycle day, and phase
|
|
// For 28-day cycle, day 15 is EARLY_LUTEAL (days 15-21)
|
|
const todayCell = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
expect(todayCell).toHaveClass("ring-2", "ring-black");
|
|
});
|
|
|
|
it("does not highlight non-today dates", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Jan 1 is not today - aria-label includes date, cycle day, and phase
|
|
const otherCell = screen.getByRole("button", {
|
|
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
|
|
});
|
|
expect(otherCell).not.toHaveClass("ring-2");
|
|
});
|
|
});
|
|
|
|
describe("phase colors", () => {
|
|
it("applies menstrual phase color to days 1-3", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Days 1-3 are MENSTRUAL (bg-blue-100)
|
|
const day1 = screen.getByRole("button", {
|
|
name: /January 1, 2026 - Cycle day 1 - Menstrual phase/i,
|
|
});
|
|
expect(day1).toHaveClass("bg-blue-100");
|
|
});
|
|
|
|
it("applies follicular phase color to days 4-12", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// For 28-day cycle, FOLLICULAR is days 4-12
|
|
const day5 = screen.getByRole("button", {
|
|
name: /January 5, 2026 - Cycle day 5 - Follicular phase/i,
|
|
});
|
|
expect(day5).toHaveClass("bg-green-100");
|
|
});
|
|
|
|
it("applies ovulation phase color to days 13-14", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// For 28-day cycle, OVULATION is days 13-14
|
|
const day13 = screen.getByRole("button", {
|
|
name: /January 13, 2026 - Cycle day 13 - Ovulation phase/i,
|
|
});
|
|
expect(day13).toHaveClass("bg-purple-100");
|
|
});
|
|
|
|
it("applies early luteal phase color to days 15-21", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// For 28-day cycle, EARLY_LUTEAL is days 15-21
|
|
const day18 = screen.getByRole("button", {
|
|
name: /January 18, 2026 - Cycle day 18 - Early Luteal phase/i,
|
|
});
|
|
expect(day18).toHaveClass("bg-yellow-100");
|
|
});
|
|
|
|
it("applies late luteal phase color to days 22-28", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// For 28-day cycle, LATE_LUTEAL is days 22-28
|
|
const day25 = screen.getByRole("button", {
|
|
name: /January 25, 2026 - Cycle day 25 - Late Luteal phase/i,
|
|
});
|
|
expect(day25).toHaveClass("bg-red-100");
|
|
});
|
|
});
|
|
|
|
describe("navigation", () => {
|
|
it("renders previous month button", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /previous month/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders next month button", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /next month/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders Today button", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(
|
|
screen.getByRole("button", { name: /^today$/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls onMonthChange with previous month when clicking previous", () => {
|
|
const onMonthChange = vi.fn();
|
|
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
|
|
|
|
const prevButton = screen.getByRole("button", {
|
|
name: /previous month/i,
|
|
});
|
|
fireEvent.click(prevButton);
|
|
|
|
expect(onMonthChange).toHaveBeenCalledWith(2025, 11); // December 2025
|
|
});
|
|
|
|
it("calls onMonthChange with next month when clicking next", () => {
|
|
const onMonthChange = vi.fn();
|
|
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
|
|
|
|
const nextButton = screen.getByRole("button", { name: /next month/i });
|
|
fireEvent.click(nextButton);
|
|
|
|
expect(onMonthChange).toHaveBeenCalledWith(2026, 1); // February 2026
|
|
});
|
|
|
|
it("calls onMonthChange with current month when clicking Today", () => {
|
|
const onMonthChange = vi.fn();
|
|
// Render a different month than current (March 2026)
|
|
render(
|
|
<MonthView
|
|
{...baseProps}
|
|
year={2026}
|
|
month={2}
|
|
onMonthChange={onMonthChange}
|
|
/>,
|
|
);
|
|
|
|
const todayButton = screen.getByRole("button", { name: /^today$/i });
|
|
fireEvent.click(todayButton);
|
|
|
|
// Should navigate to January 2026 (month of mocked "today")
|
|
expect(onMonthChange).toHaveBeenCalledWith(2026, 0);
|
|
});
|
|
});
|
|
|
|
describe("phase legend", () => {
|
|
it("renders phase legend", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(screen.getByText(/menstrual/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/follicular/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/ovulation/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("cycle rollover", () => {
|
|
it("handles cycle rollover correctly", () => {
|
|
// Last period was Dec 5, 2025 with 28-day cycle
|
|
// Jan 1, 2026 = day 28 of cycle
|
|
// Jan 2, 2026 = day 1 of new cycle
|
|
render(
|
|
<MonthView
|
|
{...baseProps}
|
|
lastPeriodDate={new Date("2025-12-05")}
|
|
cycleLength={28}
|
|
/>,
|
|
);
|
|
|
|
// Jan 1 should be day 28 (late luteal)
|
|
// Button now has aria-label with full date, cycle day, and phase
|
|
const jan1 = screen.getByRole("button", {
|
|
name: /January 1, 2026 - Cycle day 28 - Late Luteal phase/i,
|
|
});
|
|
expect(jan1).toHaveClass("bg-red-100"); // LATE_LUTEAL
|
|
|
|
// Jan 2 should be day 1 (menstrual)
|
|
const jan2 = screen.getByRole("button", {
|
|
name: /January 2, 2026 - Cycle day 1 - Menstrual phase/i,
|
|
});
|
|
expect(jan2).toHaveClass("bg-blue-100"); // MENSTRUAL
|
|
});
|
|
});
|
|
|
|
describe("different month displays", () => {
|
|
it("renders February 2026 correctly", () => {
|
|
render(<MonthView {...baseProps} month={1} />);
|
|
|
|
expect(screen.getByText("February 2026")).toBeInTheDocument();
|
|
// February 2026 has 28 days (not a leap year)
|
|
expect(screen.getByText("28")).toBeInTheDocument();
|
|
expect(screen.queryByText("29")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders leap year February correctly", () => {
|
|
render(<MonthView {...baseProps} year={2024} month={1} />);
|
|
|
|
expect(screen.getByText("February 2024")).toBeInTheDocument();
|
|
// February 2024 has 29 days (leap year)
|
|
expect(screen.getByText("29")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("keyboard navigation", () => {
|
|
// For 28-day cycle:
|
|
// MENSTRUAL: 1-3, FOLLICULAR: 4-12, OVULATION: 13-14, EARLY_LUTEAL: 15-21, LATE_LUTEAL: 22-28
|
|
it("moves focus to next day when pressing ArrowRight", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
|
const jan15 = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
jan15.focus();
|
|
|
|
// Press ArrowRight to move to Jan 16 - day 16 is EARLY_LUTEAL
|
|
fireEvent.keyDown(jan15, { key: "ArrowRight" });
|
|
|
|
const jan16 = screen.getByRole("button", {
|
|
name: /January 16, 2026 - Cycle day 16 - Early Luteal phase$/i,
|
|
});
|
|
expect(document.activeElement).toBe(jan16);
|
|
});
|
|
|
|
it("moves focus to previous day when pressing ArrowLeft", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
|
const jan15 = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
jan15.focus();
|
|
|
|
// Press ArrowLeft to move to Jan 14 - day 14 is OVULATION
|
|
fireEvent.keyDown(jan15, { key: "ArrowLeft" });
|
|
|
|
const jan14 = screen.getByRole("button", {
|
|
name: /January 14, 2026 - Cycle day 14 - Ovulation phase$/i,
|
|
});
|
|
expect(document.activeElement).toBe(jan14);
|
|
});
|
|
|
|
it("moves focus to same day next week when pressing ArrowDown", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
|
const jan15 = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
jan15.focus();
|
|
|
|
// Press ArrowDown to move to Jan 22 (7 days later) - day 22 is LATE_LUTEAL
|
|
fireEvent.keyDown(jan15, { key: "ArrowDown" });
|
|
|
|
const jan22 = screen.getByRole("button", {
|
|
name: /January 22, 2026 - Cycle day 22 - Late Luteal phase$/i,
|
|
});
|
|
expect(document.activeElement).toBe(jan22);
|
|
});
|
|
|
|
it("moves focus to same day previous week when pressing ArrowUp", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Focus on Jan 15 (today) - for 28-day cycle, day 15 is EARLY_LUTEAL
|
|
const jan15 = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
jan15.focus();
|
|
|
|
// Press ArrowUp to move to Jan 8 (7 days earlier) - day 8 is FOLLICULAR
|
|
fireEvent.keyDown(jan15, { key: "ArrowUp" });
|
|
|
|
const jan8 = screen.getByRole("button", {
|
|
name: /January 8, 2026 - Cycle day 8 - Follicular phase$/i,
|
|
});
|
|
expect(document.activeElement).toBe(jan8);
|
|
});
|
|
|
|
it("calls onMonthChange when navigating past end of month with ArrowRight", () => {
|
|
const onMonthChange = vi.fn();
|
|
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
|
|
|
|
// Focus on Jan 31 (last day of month)
|
|
// With lastPeriod Jan 1, cycleLength 28: Jan 31 = cycle day 3 (MENSTRUAL)
|
|
const jan31 = screen.getByRole("button", {
|
|
name: /January 31, 2026 - Cycle day 3 - Menstrual phase$/i,
|
|
});
|
|
jan31.focus();
|
|
|
|
// Press ArrowRight - should trigger month change to February
|
|
fireEvent.keyDown(jan31, { key: "ArrowRight" });
|
|
|
|
expect(onMonthChange).toHaveBeenCalledWith(2026, 1);
|
|
});
|
|
|
|
it("calls onMonthChange when navigating before start of month with ArrowLeft", () => {
|
|
const onMonthChange = vi.fn();
|
|
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
|
|
|
|
// Focus on Jan 1 (first day of month)
|
|
const jan1 = screen.getByRole("button", {
|
|
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
|
|
});
|
|
jan1.focus();
|
|
|
|
// Press ArrowLeft - should trigger month change to December 2025
|
|
fireEvent.keyDown(jan1, { key: "ArrowLeft" });
|
|
|
|
expect(onMonthChange).toHaveBeenCalledWith(2025, 11);
|
|
});
|
|
|
|
it("wraps focus at row boundaries for Home and End keys", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
|
|
const jan15 = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
jan15.focus();
|
|
|
|
// Press Home to move to first day of month
|
|
fireEvent.keyDown(jan15, { key: "Home" });
|
|
|
|
const jan1 = screen.getByRole("button", {
|
|
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
|
|
});
|
|
expect(document.activeElement).toBe(jan1);
|
|
});
|
|
|
|
it("moves focus to last day when pressing End key", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
// Focus on Jan 15 - for 28-day cycle, day 15 is EARLY_LUTEAL
|
|
const jan15 = screen.getByRole("button", {
|
|
name: /January 15, 2026 - Cycle day 15 - Early Luteal phase \(today\)/i,
|
|
});
|
|
jan15.focus();
|
|
|
|
// Press End to move to last day of month
|
|
fireEvent.keyDown(jan15, { key: "End" });
|
|
|
|
// With lastPeriod Jan 1, cycleLength 28: Jan 31 = cycle day 3 (MENSTRUAL)
|
|
const jan31 = screen.getByRole("button", {
|
|
name: /January 31, 2026 - Cycle day 3 - Menstrual phase$/i,
|
|
});
|
|
expect(document.activeElement).toBe(jan31);
|
|
});
|
|
|
|
it("calendar grid has proper role for accessibility", () => {
|
|
render(<MonthView {...baseProps} />);
|
|
|
|
expect(screen.getByRole("grid")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|