Add period date setup modal for new users
All checks were successful
Deploy / deploy (push) Successful in 2m27s

Users without a lastPeriodDate can now set it via a modal opened from
the onboarding banner. The dashboard now fetches user data independently
so the banner shows even when /api/today fails due to missing period date.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 14:28:49 +00:00
parent 72706bb91b
commit 0e585e6bb4
4 changed files with 771 additions and 34 deletions

View File

@@ -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(<PeriodDateModal {...defaultProps} />);
expect(
screen.getByRole("dialog", { name: /set period date/i }),
).toBeInTheDocument();
});
it("does not render when isOpen is false", () => {
render(<PeriodDateModal {...defaultProps} isOpen={false} />);
expect(
screen.queryByRole("dialog", { name: /set period date/i }),
).not.toBeInTheDocument();
});
});
describe("form elements", () => {
it("renders a date input", () => {
render(<PeriodDateModal {...defaultProps} />);
expect(
screen.getByLabelText(/when did your last period start/i),
).toBeInTheDocument();
});
it("renders a cancel button", () => {
render(<PeriodDateModal {...defaultProps} />);
expect(
screen.getByRole("button", { name: /cancel/i }),
).toBeInTheDocument();
});
it("renders a submit button", () => {
render(<PeriodDateModal {...defaultProps} />);
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
});
it("sets max date to today to prevent future dates", () => {
render(<PeriodDateModal {...defaultProps} />);
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(<PeriodDateModal {...defaultProps} onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when backdrop is clicked", () => {
const onClose = vi.fn();
render(<PeriodDateModal {...defaultProps} onClose={onClose} />);
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(<PeriodDateModal {...defaultProps} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not close when clicking inside the modal content", () => {
const onClose = vi.fn();
render(<PeriodDateModal {...defaultProps} onClose={onClose} />);
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(<PeriodDateModal {...defaultProps} onSubmit={onSubmit} />);
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(<PeriodDateModal {...defaultProps} onSubmit={onSubmit} />);
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(<PeriodDateModal {...defaultProps} onSubmit={onSubmit} />);
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(<PeriodDateModal {...defaultProps} onSubmit={onSubmit} />);
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(<PeriodDateModal {...defaultProps} onSubmit={onSubmit} />);
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(<PeriodDateModal {...defaultProps} onSubmit={onSubmit} />);
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(<PeriodDateModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-labelledby");
});
it("focuses the date input when modal opens", async () => {
render(<PeriodDateModal {...defaultProps} />);
const input = screen.getByLabelText(/when did your last period start/i);
await waitFor(() => {
expect(document.activeElement).toBe(input);
});
});
it("has aria-modal attribute", () => {
render(<PeriodDateModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
});
});
});

View File

@@ -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<void>;
}
export function PeriodDateModal({
isOpen,
onClose,
onSubmit,
}: PeriodDateModalProps) {
const [selectedDate, setSelectedDate] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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
<div
data-testid="modal-backdrop"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleBackdropClick}
>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
<div
role="dialog"
aria-modal="true"
aria-labelledby="period-modal-title"
className="bg-white dark:bg-zinc-800 rounded-lg shadow-xl p-6 w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<h2
id="period-modal-title"
className="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"
>
Set Period Date
</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="period-date"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
When did your last period start?
</label>
<input
ref={inputRef}
type="date"
id="period-date"
value={selectedDate}
onChange={handleDateChange}
max={today}
disabled={isSubmitting}
className="w-full px-3 py-2 border border-gray-300 dark:border-zinc-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 dark:bg-zinc-700 dark:text-gray-100 disabled:opacity-50"
/>
</div>
{error && (
<div
role="alert"
className="mb-4 p-3 text-sm text-red-700 bg-red-100 rounded-md dark:text-red-300 dark:bg-red-900/30"
>
{error}
</div>
)}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-700 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-600 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:opacity-50"
>
{isSubmitting ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
</div>
);
}