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:
453
src/app/calendar/page.test.tsx
Normal file
453
src/app/calendar/page.test.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
// ABOUTME: Unit tests for the Calendar page component.
|
||||
// ABOUTME: Tests calendar rendering, month navigation, ICS subscription, and token regeneration.
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
import CalendarPage from "./page";
|
||||
|
||||
describe("CalendarPage", () => {
|
||||
const mockUser = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
cycleLength: 28,
|
||||
lastPeriodDate: "2026-01-01",
|
||||
calendarToken: "abc123def456",
|
||||
garminConnected: false,
|
||||
activeOverrides: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the calendar heading", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /^calendar$/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the MonthView component with current month", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
const today = new Date();
|
||||
const expectedMonth = today.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(expectedMonth)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders day-of-week headers from MonthView", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Sun")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders calendar days", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// January has 31 days
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("31")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders back link to dashboard", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("data loading", () => {
|
||||
it("fetches user data on mount", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/user");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching", async () => {
|
||||
let resolveUser: (value: unknown) => void = () => {};
|
||||
const userPromise = new Promise((resolve) => {
|
||||
resolveUser = resolve;
|
||||
});
|
||||
mockFetch.mockReturnValue({
|
||||
ok: true,
|
||||
json: () => userPromise,
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
|
||||
resolveUser(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error if fetching fails", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "Failed to fetch user" }),
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("month navigation", () => {
|
||||
it("navigates to previous month when clicking previous button", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = today.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const prevMonth = new Date(today.getFullYear(), today.getMonth() - 1);
|
||||
const prevMonthStr = prevMonth.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const prevButton = screen.getByRole("button", {
|
||||
name: /previous month/i,
|
||||
});
|
||||
fireEvent.click(prevButton);
|
||||
|
||||
expect(screen.getByText(prevMonthStr)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to next month when clicking next button", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = today.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1);
|
||||
const nextMonthStr = nextMonth.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next month/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(screen.getByText(nextMonthStr)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to current month when clicking Today button", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = today.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1);
|
||||
const nextMonthStr = nextMonth.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Navigate away first
|
||||
const nextButton = screen.getByRole("button", { name: /next month/i });
|
||||
fireEvent.click(nextButton);
|
||||
expect(screen.getByText(nextMonthStr)).toBeInTheDocument();
|
||||
|
||||
// Click Today to return
|
||||
const todayButton = screen.getByRole("button", { name: /^today$/i });
|
||||
fireEvent.click(todayButton);
|
||||
|
||||
expect(screen.getByText(currentMonth)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ICS subscription", () => {
|
||||
it("renders ICS subscription section", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays calendar subscription URL when token exists", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// The URL input should contain the token
|
||||
const urlInput = screen.getByRole("textbox") as HTMLInputElement;
|
||||
expect(urlInput.value).toContain("abc123def456.ics");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows instructions for subscribing", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/subscribe to this calendar/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders copy URL button", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /copy/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders regenerate token button", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /regenerate/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("token regeneration", () => {
|
||||
it("calls regenerate API when clicking regenerate button", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
token: "newtoken123",
|
||||
url: "http://localhost/api/calendar/user123/newtoken123.ics",
|
||||
}),
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /regenerate/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const regenerateButton = screen.getByRole("button", {
|
||||
name: /regenerate/i,
|
||||
});
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/calendar/regenerate-token",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("updates displayed URL after regeneration", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
token: "newtoken123",
|
||||
url: "http://localhost/api/calendar/user123/newtoken123.ics",
|
||||
}),
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const urlInput = screen.getByRole("textbox") as HTMLInputElement;
|
||||
expect(urlInput.value).toContain("abc123def456.ics");
|
||||
});
|
||||
|
||||
const regenerateButton = screen.getByRole("button", {
|
||||
name: /regenerate/i,
|
||||
});
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const urlInput = screen.getByRole("textbox") as HTMLInputElement;
|
||||
expect(urlInput.value).toContain("newtoken123.ics");
|
||||
});
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows confirmation dialog before regenerating", async () => {
|
||||
// Mock window.confirm
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
token: "newtoken123",
|
||||
url: "http://localhost/api/calendar/user123/newtoken123.ics",
|
||||
}),
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /regenerate/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const regenerateButton = screen.getByRole("button", {
|
||||
name: /regenerate/i,
|
||||
});
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not regenerate if user cancels confirmation", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /regenerate/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const regenerateButton = screen.getByRole("button", {
|
||||
name: /regenerate/i,
|
||||
});
|
||||
fireEvent.click(regenerateButton);
|
||||
|
||||
// Should not call the regenerate API
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // Only initial fetch
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase legend", () => {
|
||||
it("displays phase legend from MonthView", async () => {
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
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("no calendar token", () => {
|
||||
it("shows generate button when no token exists", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ...mockUser, calendarToken: null }),
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /generate calendar url/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show URL when no token exists", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ...mockUser, calendarToken: null }),
|
||||
});
|
||||
|
||||
render(<CalendarPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/\.ics/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,201 @@
|
||||
// ABOUTME: In-app calendar view showing cycle phases.
|
||||
// ABOUTME: Displays monthly calendar with color-coded phases and ICS subscription info.
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MonthView } from "@/components/calendar/month-view";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
cycleLength: number;
|
||||
lastPeriodDate: string;
|
||||
calendarToken: string | null;
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth());
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchUser() {
|
||||
try {
|
||||
const res = await fetch("/api/user");
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Failed to fetch user");
|
||||
return;
|
||||
}
|
||||
setUser(data);
|
||||
} catch {
|
||||
setError("Failed to fetch user data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
const handleMonthChange = (newYear: number, newMonth: number) => {
|
||||
setYear(newYear);
|
||||
setMonth(newMonth);
|
||||
};
|
||||
|
||||
const getCalendarUrl = () => {
|
||||
if (!user?.calendarToken) return null;
|
||||
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||
return `${baseUrl}/api/calendar/${user.id}/${user.calendarToken}.ics`;
|
||||
};
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
const url = getCalendarUrl();
|
||||
if (url) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
const confirmed = window.confirm(
|
||||
"Regenerating the calendar URL will invalidate any existing calendar subscriptions. Continue?",
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const res = await fetch("/api/calendar/regenerate-token", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && user) {
|
||||
setUser({ ...user, calendarToken: data.token });
|
||||
}
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateToken = async () => {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
const res = await fetch("/api/calendar/regenerate-token", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && user) {
|
||||
setUser({ ...user, calendarToken: data.token });
|
||||
}
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<div role="alert" className="text-red-600 mb-4">
|
||||
{error}
|
||||
</div>
|
||||
<Link href="/" className="text-blue-600 hover:underline">
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const calendarUrl = getCalendarUrl();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Calendar</h1>
|
||||
{/* Calendar view will be implemented here */}
|
||||
<p className="text-gray-500">Calendar view placeholder</p>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold">Calendar</h1>
|
||||
<Link href="/" className="text-blue-600 hover:underline">
|
||||
← Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<MonthView
|
||||
year={year}
|
||||
month={month}
|
||||
lastPeriodDate={new Date(user.lastPeriodDate)}
|
||||
cycleLength={user.cycleLength}
|
||||
onMonthChange={handleMonthChange}
|
||||
/>
|
||||
|
||||
{/* ICS Subscription Section */}
|
||||
<div className="mt-8 p-4 border rounded-lg">
|
||||
<h2 className="text-lg font-semibold mb-4">Calendar Subscription</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Subscribe to this calendar in Google Calendar, Apple Calendar, or
|
||||
other calendar apps to see your cycle phases.
|
||||
</p>
|
||||
|
||||
{calendarUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={calendarUrl}
|
||||
className="flex-1 p-2 border rounded bg-gray-50 text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="px-4 py-2 border border-red-600 text-red-600 rounded hover:bg-red-50 disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? "Regenerating..." : "Regenerate URL"}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">
|
||||
Warning: Regenerating the URL will invalidate any existing
|
||||
subscriptions.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Generate a calendar URL to subscribe in external calendar apps.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateToken}
|
||||
disabled={regenerating}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? "Generating..." : "Generate Calendar URL"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
249
src/components/calendar/month-view.test.tsx
Normal file
249
src/components/calendar/month-view.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user