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,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();
});
});
});

View File

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