Implement Calendar page with MonthView and ICS subscription (P2.11)

- Complete MonthView component with calendar grid, DayCell integration,
  navigation controls (prev/next month, Today button), and phase legend
- Implement Calendar page with MonthView, month navigation state,
  ICS subscription section with URL display, copy, and token regeneration
- Add 21 tests for MonthView component (calendar grid, phase colors,
  navigation, legend, cycle rollover)
- Add 23 tests for Calendar page (rendering, navigation, ICS subscription,
  token regeneration, error handling)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 21:42:40 +00:00
parent 742f220be5
commit 97a424e41d
5 changed files with 1050 additions and 22 deletions

View File

@@ -0,0 +1,249 @@
// 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" - find the button containing "15"
const todayCell = screen.getByRole("button", { name: /^15\s*Day 15/i });
expect(todayCell).toHaveClass("ring-2", "ring-black");
});
it("does not highlight non-today dates", () => {
render(<MonthView {...baseProps} />);
// Jan 1 is not today
const otherCell = screen.getByRole("button", { name: /^1\s*Day 1/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: /^1\s*Day 1/i });
expect(day1).toHaveClass("bg-blue-100");
});
it("applies follicular phase color to days 4-14", () => {
render(<MonthView {...baseProps} />);
// Day 5 is FOLLICULAR (bg-green-100)
const day5 = screen.getByRole("button", { name: /^5\s*Day 5/i });
expect(day5).toHaveClass("bg-green-100");
});
it("applies ovulation phase color to days 15-16", () => {
render(<MonthView {...baseProps} />);
// Day 15 is OVULATION (bg-purple-100)
const day15 = screen.getByRole("button", { name: /^15\s*Day 15/i });
expect(day15).toHaveClass("bg-purple-100");
});
it("applies early luteal phase color to days 17-24", () => {
render(<MonthView {...baseProps} />);
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
const day20 = screen.getByRole("button", { name: /^20\s*Day 20/i });
expect(day20).toHaveClass("bg-yellow-100");
});
it("applies late luteal phase color to days 25-31", () => {
render(<MonthView {...baseProps} />);
// Day 25 is LATE_LUTEAL (bg-red-100)
const day25 = screen.getByRole("button", { name: /^25\s*Day 25/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)
const jan1 = screen.getByRole("button", { name: /^1\s*Day 28/i });
expect(jan1).toHaveClass("bg-red-100"); // LATE_LUTEAL
// Jan 2 should be day 1 (menstrual)
const jan2 = screen.getByRole("button", { name: /^2\s*Day 1/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();
});
});
});

View File

@@ -1,10 +1,34 @@
// ABOUTME: Full month calendar view component.
// ABOUTME: Displays calendar grid with phase colors and day details.
"use client";
import { getCycleDay, getPhase } from "@/lib/cycle";
import { DayCell } from "./day-cell";
interface MonthViewProps {
year: number;
month: number;
lastPeriodDate: Date;
cycleLength: number;
onMonthChange?: (year: number, month: number) => void;
}
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const PHASE_LEGEND = [
{ name: "Menstrual", color: "bg-blue-100" },
{ name: "Follicular", color: "bg-green-100" },
{ name: "Ovulation", color: "bg-purple-100" },
{ name: "Early Luteal", color: "bg-yellow-100" },
{ name: "Late Luteal", color: "bg-red-100" },
];
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
export function MonthView({
@@ -12,21 +36,129 @@ export function MonthView({
month,
lastPeriodDate,
cycleLength,
onMonthChange,
}: MonthViewProps) {
const today = new Date();
const daysInMonth = getDaysInMonth(year, month);
const firstDayOfWeek = getFirstDayOfMonth(year, month);
const handlePreviousMonth = () => {
if (month === 0) {
onMonthChange?.(year - 1, 11);
} else {
onMonthChange?.(year, month - 1);
}
};
const handleNextMonth = () => {
if (month === 11) {
onMonthChange?.(year + 1, 0);
} else {
onMonthChange?.(year, month + 1);
}
};
const handleTodayClick = () => {
onMonthChange?.(today.getFullYear(), today.getMonth());
};
const days: (Date | null)[] = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < firstDayOfWeek; i++) {
days.push(null);
}
// Add the actual days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
return (
<div className="rounded-lg border p-4">
<h2 className="text-xl font-bold mb-4">
{new Date(year, month).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
{/* Header with navigation */}
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={handlePreviousMonth}
className="p-2 hover:bg-gray-100 rounded"
aria-label="Previous month"
>
</button>
<div className="flex items-center gap-4">
<h2 className="text-xl font-bold">
{new Date(year, month).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})}
</h2>
<button
type="button"
onClick={handleTodayClick}
className="px-3 py-1 text-sm border rounded hover:bg-gray-100"
>
Today
</button>
</div>
<button
type="button"
onClick={handleNextMonth}
className="p-2 hover:bg-gray-100 rounded"
aria-label="Next month"
>
</button>
</div>
{/* Day of week headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{DAY_NAMES.map((dayName) => (
<div
key={dayName}
className="text-center text-sm font-medium text-gray-500 py-1"
>
{dayName}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{days.map((date, index) => {
if (!date) {
// biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month
return <div key={`empty-${index}`} className="p-2" />;
}
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
const phase = getPhase(cycleDay);
const isToday =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
return (
<DayCell
key={date.toISOString()}
date={date}
cycleDay={cycleDay}
phase={phase}
isToday={isToday}
/>
);
})}
</h2>
{/* Calendar grid will be implemented here */}
<p className="text-gray-500">Month view placeholder</p>
<p className="text-xs text-gray-400">
Cycle length: {cycleLength} days, Last period:{" "}
{lastPeriodDate.toLocaleDateString()}
</p>
</div>
{/* Phase legend */}
<div className="mt-4 flex flex-wrap gap-3 justify-center">
{PHASE_LEGEND.map((phase) => (
<div key={phase.name} className="flex items-center gap-1">
<div className={`w-4 h-4 rounded ${phase.color}`} />
<span className="text-xs text-gray-600">{phase.name}</span>
</div>
))}
</div>
</div>
);
}