diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index ae266a1..ffe9de7 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -500,11 +500,16 @@ describe("Dashboard", () => { describe("error handling", () => { it("shows error message when /api/today fails", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: "Internal server error" }), - }); + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: "Internal server error" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockUserResponse), + }); render(); @@ -515,20 +520,31 @@ describe("Dashboard", () => { }); it("shows setup message when user has no lastPeriodDate", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: - "User has no lastPeriodDate set. Please log your period start date first.", - }), - }); + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: null, + }), + }); render(); await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); + // Multiple alerts may be present (error alert + onboarding banner) + const alerts = screen.getAllByRole("alert"); + expect(alerts.length).toBeGreaterThan(0); // Check for the specific help text about getting started expect( screen.getByText(/please log your period start date to get started/i), @@ -723,4 +739,295 @@ describe("Dashboard", () => { }); }); }); + + describe("period date modal flow", () => { + it("shows onboarding banner when todayData fails but userData shows no lastPeriodDate", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/Set your last period date for accurate tracking/i), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /set date/i }), + ).toBeInTheDocument(); + }); + }); + + it("opens period date modal when clicking Set date button", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /set date/i }), + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /set date/i })); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /set period date/i }), + ).toBeInTheDocument(); + }); + }); + + it("calls POST /api/cycle/period when submitting date in modal", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /set date/i }), + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /set date/i })); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /set period date/i }), + ).toBeInTheDocument(); + }); + + // Set up mock for the period POST and subsequent refetch + mockFetch.mockClear(); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + message: "Period start date logged successfully", + lastPeriodDate: "2024-01-15", + cycleDay: 1, + phase: "MENSTRUAL", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTodayResponse), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: "2024-01-15", + }), + }); + + const dateInput = screen.getByLabelText( + /when did your last period start/i, + ); + fireEvent.change(dateInput, { target: { value: "2024-01-15" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/cycle/period", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ startDate: "2024-01-15" }), + }); + }); + }); + + it("closes modal and refetches data after successful submission", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /set date/i }), + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /set date/i })); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /set period date/i }), + ).toBeInTheDocument(); + }); + + // Set up mock for the period POST and successful refetch + mockFetch.mockClear(); + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + message: "Period start date logged successfully", + lastPeriodDate: "2024-01-15", + cycleDay: 1, + phase: "MENSTRUAL", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockTodayResponse), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: "2024-01-15", + }), + }); + + const dateInput = screen.getByLabelText( + /when did your last period start/i, + ); + fireEvent.change(dateInput, { target: { value: "2024-01-15" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + // Modal should close and dashboard should show normal content + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: /set period date/i }), + ).not.toBeInTheDocument(); + // Dashboard should now show the decision card + expect(screen.getByText("TRAIN")).toBeInTheDocument(); + }); + }); + + it("shows error in modal when API call fails", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: + "User has no lastPeriodDate set. Please log your period start date first.", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockUserResponse, + lastPeriodDate: null, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /set date/i }), + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /set date/i })); + + await waitFor(() => { + expect( + screen.getByRole("dialog", { name: /set period date/i }), + ).toBeInTheDocument(); + }); + + // Set up mock for failed API call + mockFetch.mockClear(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ error: "Failed to update period date" }), + }); + + const dateInput = screen.getByLabelText( + /when did your last period start/i, + ); + fireEvent.change(dateInput, { target: { value: "2024-01-15" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + // Error should appear in modal (there may be multiple alerts - dashboard error + modal error) + await waitFor(() => { + const alerts = screen.getAllByRole("alert"); + const modalError = alerts.find((alert) => + alert.textContent?.includes("Failed to update period date"), + ); + expect(modalError).toBeInTheDocument(); + }); + + // Modal should still be open + expect( + screen.getByRole("dialog", { name: /set period date/i }), + ).toBeInTheDocument(); + }); + }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index e853630..84725ea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -10,6 +10,7 @@ import { MiniCalendar } from "@/components/dashboard/mini-calendar"; import { NutritionPanel } from "@/components/dashboard/nutrition-panel"; import { OnboardingBanner } from "@/components/dashboard/onboarding-banner"; import { OverrideToggles } from "@/components/dashboard/override-toggles"; +import { PeriodDateModal } from "@/components/dashboard/period-date-modal"; import { DashboardSkeleton } from "@/components/dashboard/skeletons"; import type { CyclePhase, @@ -54,6 +55,7 @@ export default function Dashboard() { const [error, setError] = useState(null); const [calendarYear, setCalendarYear] = useState(new Date().getFullYear()); const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth()); + const [showPeriodModal, setShowPeriodModal] = useState(false); const handleCalendarMonthChange = useCallback( (year: number, month: number) => { @@ -87,27 +89,62 @@ export default function Dashboard() { useEffect(() => { async function loadData() { - try { - setLoading(true); - setError(null); + setLoading(true); + setError(null); - const [today, user] = await Promise.all([ - fetchTodayData(), - fetchUserData(), - ]); + // Fetch userData and todayData independently so we can show the + // onboarding banner even if todayData fails due to missing lastPeriodDate + const [todayResult, userResult] = await Promise.allSettled([ + fetchTodayData(), + fetchUserData(), + ]); - setTodayData(today); - setUserData(user); - } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); - } finally { - setLoading(false); + if (userResult.status === "fulfilled") { + setUserData(userResult.value); } + + if (todayResult.status === "fulfilled") { + setTodayData(todayResult.value); + } else { + setError( + todayResult.reason instanceof Error + ? todayResult.reason.message + : "Failed to fetch today data", + ); + } + + setLoading(false); } loadData(); }, [fetchTodayData, fetchUserData]); + const handlePeriodDateSubmit = async (date: string) => { + const response = await fetch("/api/cycle/period", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ startDate: date }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to update period date"); + } + + // Close modal and refetch all data + setShowPeriodModal(false); + setError(null); + + const [today, user] = await Promise.all([ + fetchTodayData(), + fetchUserData(), + ]); + + setTodayData(today); + setUserData(user); + }; + const handleOverrideToggle = async (override: OverrideType) => { if (!userData) return; @@ -160,12 +197,25 @@ export default function Dashboard() { {loading && } {error && ( -
-

Error: {error}

- {error.includes("lastPeriodDate") && ( -

- Please log your period start date to get started. -

+
+
+

Error: {error}

+ {error.includes("lastPeriodDate") && ( +

+ Please log your period start date to get started. +

+ )} +
+ {/* Show onboarding banner even in error state if userData shows missing period date */} + {userData && !userData.lastPeriodDate && ( + setShowPeriodModal(true)} + /> )}
)} @@ -179,6 +229,7 @@ export default function Dashboard() { lastPeriodDate: userData.lastPeriodDate, notificationTime: userData.notificationTime, }} + onSetPeriodDate={() => setShowPeriodModal(true)} /> {/* Cycle Info */} @@ -231,6 +282,12 @@ export default function Dashboard() {
)} + + setShowPeriodModal(false)} + onSubmit={handlePeriodDateSubmit} + /> ); } diff --git a/src/components/dashboard/period-date-modal.test.tsx b/src/components/dashboard/period-date-modal.test.tsx new file mode 100644 index 0000000..d0bfcfc --- /dev/null +++ b/src/components/dashboard/period-date-modal.test.tsx @@ -0,0 +1,215 @@ +// ABOUTME: Tests for PeriodDateModal component that allows users to set their last period date. +// ABOUTME: Tests modal visibility, date validation, form submission, and accessibility. + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PeriodDateModal } from "./period-date-modal"; + +describe("PeriodDateModal", () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + onSubmit: vi.fn(), + }; + + describe("visibility", () => { + it("renders when isOpen is true", () => { + render(); + expect( + screen.getByRole("dialog", { name: /set period date/i }), + ).toBeInTheDocument(); + }); + + it("does not render when isOpen is false", () => { + render(); + expect( + screen.queryByRole("dialog", { name: /set period date/i }), + ).not.toBeInTheDocument(); + }); + }); + + describe("form elements", () => { + it("renders a date input", () => { + render(); + expect( + screen.getByLabelText(/when did your last period start/i), + ).toBeInTheDocument(); + }); + + it("renders a cancel button", () => { + render(); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + + it("renders a submit button", () => { + render(); + expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument(); + }); + + it("sets max date to today to prevent future dates", () => { + render(); + const input = screen.getByLabelText(/when did your last period start/i); + const today = new Date().toISOString().split("T")[0]; + expect(input).toHaveAttribute("max", today); + }); + }); + + describe("closing behavior", () => { + it("calls onClose when cancel button is clicked", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when backdrop is clicked", () => { + const onClose = vi.fn(); + render(); + + const backdrop = screen.getByTestId("modal-backdrop"); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when ESC key is pressed", () => { + const onClose = vi.fn(); + render(); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close when clicking inside the modal content", () => { + const onClose = vi.fn(); + render(); + + const dialog = screen.getByRole("dialog"); + fireEvent.click(dialog); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe("form submission", () => { + it("calls onSubmit with the selected date when form is submitted", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + const input = screen.getByLabelText(/when did your last period start/i); + fireEvent.change(input, { target: { value: "2024-01-15" } }); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith("2024-01-15"); + }); + }); + + it("does not submit when no date is selected", async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + // Should show validation error, not call onSubmit + await waitFor(() => { + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); + + it("shows loading state during submission", async () => { + const onSubmit = vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ); + render(); + + const input = screen.getByLabelText(/when did your last period start/i); + fireEvent.change(input, { target: { value: "2024-01-15" } }); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + expect( + screen.getByRole("button", { name: /saving/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled(); + }); + + it("disables cancel button during submission", async () => { + const onSubmit = vi + .fn() + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ); + render(); + + const input = screen.getByLabelText(/when did your last period start/i); + fireEvent.change(input, { target: { value: "2024-01-15" } }); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled(); + }); + }); + + describe("error handling", () => { + it("displays error message when onSubmit throws", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("API failed")); + render(); + + const input = screen.getByLabelText(/when did your last period start/i); + fireEvent.change(input, { target: { value: "2024-01-15" } }); + + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/api failed/i)).toBeInTheDocument(); + }); + }); + + it("clears error when date is changed", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("API failed")); + render(); + + const input = screen.getByLabelText(/when did your last period start/i); + fireEvent.change(input, { target: { value: "2024-01-15" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + // Change the date + fireEvent.change(input, { target: { value: "2024-01-16" } }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); + + describe("accessibility", () => { + it("has proper dialog role and aria-label", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-labelledby"); + }); + + it("focuses the date input when modal opens", async () => { + render(); + const input = screen.getByLabelText(/when did your last period start/i); + + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + }); + + it("has aria-modal attribute", () => { + render(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); + }); +}); diff --git a/src/components/dashboard/period-date-modal.tsx b/src/components/dashboard/period-date-modal.tsx new file mode 100644 index 0000000..52cd21b --- /dev/null +++ b/src/components/dashboard/period-date-modal.tsx @@ -0,0 +1,158 @@ +// ABOUTME: Modal component for setting the last period date. +// ABOUTME: Used during onboarding when users need to initialize their cycle tracking. + +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +interface PeriodDateModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (date: string) => Promise; +} + +export function PeriodDateModal({ + isOpen, + onClose, + onSubmit, +}: PeriodDateModalProps) { + const [selectedDate, setSelectedDate] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const inputRef = useRef(null); + + const today = new Date().toISOString().split("T")[0]; + + // Focus input when modal opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + // Handle ESC key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isSubmitting) { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, isSubmitting, onClose]); + + // Clear error when date changes + const handleDateChange = useCallback( + (e: React.ChangeEvent) => { + setSelectedDate(e.target.value); + setError(null); + }, + [], + ); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!selectedDate) { + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await onSubmit(selectedDate); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save date"); + } finally { + setIsSubmitting(false); + } + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && !isSubmitting) { + onClose(); + } + }; + + if (!isOpen) { + return null; + } + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by ESC key listener + // biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature +
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */} +
e.stopPropagation()} + > +

+ Set Period Date +

+ +
+
+ + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +}