From 97a424e41de49a4bfb0f15e310e5156ead4deba3 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 21:42:40 +0000 Subject: [PATCH] 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 --- IMPLEMENTATION_PLAN.md | 20 +- src/app/calendar/page.test.tsx | 453 ++++++++++++++++++++ src/app/calendar/page.tsx | 196 ++++++++- src/components/calendar/month-view.test.tsx | 249 +++++++++++ src/components/calendar/month-view.tsx | 154 ++++++- 5 files changed, 1050 insertions(+), 22 deletions(-) create mode 100644 src/app/calendar/page.test.tsx create mode 100644 src/components/calendar/month-view.test.tsx diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index eefe2aa..ed73b30 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -48,7 +48,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Login (`/login`) | **COMPLETE** | Email/password form with auth, error handling, loading states | | Settings (`/settings`) | **COMPLETE** | Form with cycleLength, notificationTime, timezone | | Settings/Garmin (`/settings/garmin`) | **COMPLETE** | Token management UI, connection status, disconnect functionality, 27 tests | -| Calendar (`/calendar`) | Placeholder | Needs MonthView integration | +| Calendar (`/calendar`) | **COMPLETE** | MonthView with navigation, ICS subscription section, token regeneration, 23 tests | | History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests | | Plan (`/plan`) | Placeholder | Needs phase details display | @@ -61,7 +61,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `OverrideToggles` | **COMPLETE** | Toggle buttons with callbacks | | `DayCell` | **COMPLETE** | Phase-colored day with click handler | | `MiniCalendar` | **Partial (~30%)** | Has header only, **MISSING: calendar grid** | -| `MonthView` | **Partial (~30%)** | Has header only, **MISSING: calendar grid + DayCell integration** | +| `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend | ### Test Coverage | Test File | Status | @@ -91,6 +91,8 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | | `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) | | `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) | +| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 21 tests (calendar grid, phase colors, navigation, legend) | +| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -401,16 +403,16 @@ Full feature set for production use. - **Why:** Users need to manage their Garmin connection - **Depends On:** P0.4, P2.2, P2.3 -### P2.11: Calendar Page Implementation -- [ ] In-app calendar with phase visualization +### P2.11: Calendar Page Implementation ✅ COMPLETE +- [x] In-app calendar with phase visualization - **Files:** - - `src/app/calendar/page.tsx` - Month view with navigation - - `src/components/calendar/month-view.tsx` - **Complete calendar grid using DayCell** + - `src/app/calendar/page.tsx` - Month view with navigation, ICS subscription section with URL display, copy button, token regeneration + - `src/components/calendar/month-view.tsx` - Complete calendar grid with DayCell integration, navigation controls, phase legend - **Tests:** - - E2E test: navigation works, phases displayed correctly + - `src/components/calendar/month-view.test.tsx` - 21 tests covering calendar grid, phase colors, navigation, legend + - `src/app/calendar/page.test.tsx` - 23 tests covering rendering, navigation, ICS subscription, token regeneration - **Why:** Planning ahead is a key user need - **Depends On:** P2.6 -- **Note:** DayCell is **COMPLETE**, MonthView needs grid implementation (~70% remaining) ### P2.12: History Page Implementation ✅ COMPLETE - [x] View past training decisions and data @@ -602,6 +604,7 @@ P2.14 Mini calendar - [x] **NutritionPanel** - Shows seeds, carbs, keto guidance - [x] **OverrideToggles** - Toggle buttons for flare/stress/sleep/pms - [x] **DayCell** - Phase-colored calendar day cell with click handler +- [x] **MonthView** - Calendar grid with DayCell integration, navigation controls (prev/next month, Today button), phase legend, 21 tests ### API Routes - [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4) @@ -625,6 +628,7 @@ P2.14 Mini calendar - [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7) - [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9) - [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10) +- [x] **Calendar Page** - MonthView with navigation controls, ICS subscription section with URL display, copy button, token regeneration, 23 tests (P2.11) - [x] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12) ### Test Infrastructure diff --git a/src/app/calendar/page.test.tsx b/src/app/calendar/page.test.tsx new file mode 100644 index 0000000..aa36da5 --- /dev/null +++ b/src/app/calendar/page.test.tsx @@ -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(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /^calendar$/i, level: 1 }), + ).toBeInTheDocument(); + }); + }); + + it("renders the MonthView component with current month", async () => { + render(); + + 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(); + + await waitFor(() => { + expect(screen.getByText("Sun")).toBeInTheDocument(); + expect(screen.getByText("Mon")).toBeInTheDocument(); + }); + }); + + it("renders calendar days", async () => { + render(); + + await waitFor(() => { + // January has 31 days + expect(screen.getByText("15")).toBeInTheDocument(); + expect(screen.getByText("31")).toBeInTheDocument(); + }); + }); + + it("renders back link to dashboard", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute( + "href", + "/", + ); + }); + }); + }); + + describe("data loading", () => { + it("fetches user data on mount", async () => { + render(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + }); + }); + + describe("month navigation", () => { + it("navigates to previous month when clicking previous button", async () => { + render(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument(); + }); + }); + + it("displays calendar subscription URL when token exists", async () => { + render(); + + 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(); + + await waitFor(() => { + expect( + screen.getByText(/subscribe to this calendar/i), + ).toBeInTheDocument(); + }); + }); + + it("renders copy URL button", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /copy/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders regenerate token button", async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText(/calendar subscription/i)).toBeInTheDocument(); + }); + + expect(screen.queryByText(/\.ics/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 93762a2..5bf7ef2 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+ + ← Back to Dashboard + +
+ ); + } + + if (!user) { + return null; + } + + const calendarUrl = getCalendarUrl(); + return (
-

Calendar

- {/* Calendar view will be implemented here */} -

Calendar view placeholder

+
+

Calendar

+ + ← Back + +
+ + + + {/* ICS Subscription Section */} +
+

Calendar Subscription

+

+ Subscribe to this calendar in Google Calendar, Apple Calendar, or + other calendar apps to see your cycle phases. +

+ + {calendarUrl ? ( +
+
+ + +
+ +

+ Warning: Regenerating the URL will invalidate any existing + subscriptions. +

+
+ ) : ( +
+

+ Generate a calendar URL to subscribe in external calendar apps. +

+ +
+ )} +
); } diff --git a/src/components/calendar/month-view.test.tsx b/src/components/calendar/month-view.test.tsx new file mode 100644 index 0000000..455d43d --- /dev/null +++ b/src/components/calendar/month-view.test.tsx @@ -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(); + + expect(screen.getByText("January 2026")).toBeInTheDocument(); + }); + + it("renders day-of-week headers", () => { + render(); + + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + expect( + screen.getByRole("button", { name: /previous month/i }), + ).toBeInTheDocument(); + }); + + it("renders next month button", () => { + render(); + + expect( + screen.getByRole("button", { name: /next month/i }), + ).toBeInTheDocument(); + }); + + it("renders Today button", () => { + render(); + + expect( + screen.getByRole("button", { name: /^today$/i }), + ).toBeInTheDocument(); + }); + + it("calls onMonthChange with previous month when clicking previous", () => { + const onMonthChange = vi.fn(); + render(); + + 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(); + + 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( + , + ); + + 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(); + + 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( + , + ); + + // 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(); + + 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(); + + expect(screen.getByText("February 2024")).toBeInTheDocument(); + // February 2024 has 29 days (leap year) + expect(screen.getByText("29")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/calendar/month-view.tsx b/src/components/calendar/month-view.tsx index 4a3994b..a60465c 100644 --- a/src/components/calendar/month-view.tsx +++ b/src/components/calendar/month-view.tsx @@ -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 (
-

- {new Date(year, month).toLocaleDateString("en-US", { - month: "long", - year: "numeric", + {/* Header with navigation */} +
+ +
+

+ {new Date(year, month).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })} +

+ +
+ +
+ + {/* Day of week headers */} +
+ {DAY_NAMES.map((dayName) => ( +
+ {dayName} +
+ ))} +
+ + {/* Calendar grid */} +
+ {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
; + } + + 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 ( + + ); })} -

- {/* Calendar grid will be implemented here */} -

Month view placeholder

-

- Cycle length: {cycleLength} days, Last period:{" "} - {lastPeriodDate.toLocaleDateString()} -

+
+ + {/* Phase legend */} +
+ {PHASE_LEGEND.map((phase) => ( +
+
+ {phase.name} +
+ ))} +
); }