Add period date setup modal for new users
All checks were successful
Deploy / deploy (push) Successful in 2m27s
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:
@@ -500,11 +500,16 @@ describe("Dashboard", () => {
|
|||||||
|
|
||||||
describe("error handling", () => {
|
describe("error handling", () => {
|
||||||
it("shows error message when /api/today fails", async () => {
|
it("shows error message when /api/today fails", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch
|
||||||
ok: false,
|
.mockResolvedValueOnce({
|
||||||
status: 500,
|
ok: false,
|
||||||
json: () => Promise.resolve({ error: "Internal server error" }),
|
status: 500,
|
||||||
});
|
json: () => Promise.resolve({ error: "Internal server error" }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockUserResponse),
|
||||||
|
});
|
||||||
|
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
|
|
||||||
@@ -515,20 +520,31 @@ describe("Dashboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows setup message when user has no lastPeriodDate", async () => {
|
it("shows setup message when user has no lastPeriodDate", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch
|
||||||
ok: false,
|
.mockResolvedValueOnce({
|
||||||
status: 400,
|
ok: false,
|
||||||
json: () =>
|
status: 400,
|
||||||
Promise.resolve({
|
json: () =>
|
||||||
error:
|
Promise.resolve({
|
||||||
"User has no lastPeriodDate set. Please log your period start date first.",
|
error:
|
||||||
}),
|
"User has no lastPeriodDate set. Please log your period start date first.",
|
||||||
});
|
}),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockUserResponse,
|
||||||
|
lastPeriodDate: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
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
|
// Check for the specific help text about getting started
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/please log your period start date to get started/i),
|
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(<Dashboard />);
|
||||||
|
|
||||||
|
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(<Dashboard />);
|
||||||
|
|
||||||
|
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(<Dashboard />);
|
||||||
|
|
||||||
|
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(<Dashboard />);
|
||||||
|
|
||||||
|
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(<Dashboard />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MiniCalendar } from "@/components/dashboard/mini-calendar";
|
|||||||
import { NutritionPanel } from "@/components/dashboard/nutrition-panel";
|
import { NutritionPanel } from "@/components/dashboard/nutrition-panel";
|
||||||
import { OnboardingBanner } from "@/components/dashboard/onboarding-banner";
|
import { OnboardingBanner } from "@/components/dashboard/onboarding-banner";
|
||||||
import { OverrideToggles } from "@/components/dashboard/override-toggles";
|
import { OverrideToggles } from "@/components/dashboard/override-toggles";
|
||||||
|
import { PeriodDateModal } from "@/components/dashboard/period-date-modal";
|
||||||
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
|
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
|
||||||
import type {
|
import type {
|
||||||
CyclePhase,
|
CyclePhase,
|
||||||
@@ -54,6 +55,7 @@ export default function Dashboard() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [calendarYear, setCalendarYear] = useState(new Date().getFullYear());
|
const [calendarYear, setCalendarYear] = useState(new Date().getFullYear());
|
||||||
const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth());
|
const [calendarMonth, setCalendarMonth] = useState(new Date().getMonth());
|
||||||
|
const [showPeriodModal, setShowPeriodModal] = useState(false);
|
||||||
|
|
||||||
const handleCalendarMonthChange = useCallback(
|
const handleCalendarMonthChange = useCallback(
|
||||||
(year: number, month: number) => {
|
(year: number, month: number) => {
|
||||||
@@ -87,27 +89,62 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
setLoading(true);
|
||||||
setLoading(true);
|
setError(null);
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const [today, user] = await Promise.all([
|
// Fetch userData and todayData independently so we can show the
|
||||||
fetchTodayData(),
|
// onboarding banner even if todayData fails due to missing lastPeriodDate
|
||||||
fetchUserData(),
|
const [todayResult, userResult] = await Promise.allSettled([
|
||||||
]);
|
fetchTodayData(),
|
||||||
|
fetchUserData(),
|
||||||
|
]);
|
||||||
|
|
||||||
setTodayData(today);
|
if (userResult.status === "fulfilled") {
|
||||||
setUserData(user);
|
setUserData(userResult.value);
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "An error occurred");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (todayResult.status === "fulfilled") {
|
||||||
|
setTodayData(todayResult.value);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
todayResult.reason instanceof Error
|
||||||
|
? todayResult.reason.message
|
||||||
|
: "Failed to fetch today data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [fetchTodayData, fetchUserData]);
|
}, [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) => {
|
const handleOverrideToggle = async (override: OverrideType) => {
|
||||||
if (!userData) return;
|
if (!userData) return;
|
||||||
|
|
||||||
@@ -160,12 +197,25 @@ export default function Dashboard() {
|
|||||||
{loading && <DashboardSkeleton />}
|
{loading && <DashboardSkeleton />}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div role="alert" className="text-center py-12">
|
<div className="space-y-6">
|
||||||
<p className="text-red-500">Error: {error}</p>
|
<div role="alert" className="text-center py-12">
|
||||||
{error.includes("lastPeriodDate") && (
|
<p className="text-red-500">Error: {error}</p>
|
||||||
<p className="text-sm text-zinc-400 mt-2">
|
{error.includes("lastPeriodDate") && (
|
||||||
Please log your period start date to get started.
|
<p className="text-sm text-zinc-400 mt-2">
|
||||||
</p>
|
Please log your period start date to get started.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Show onboarding banner even in error state if userData shows missing period date */}
|
||||||
|
{userData && !userData.lastPeriodDate && (
|
||||||
|
<OnboardingBanner
|
||||||
|
status={{
|
||||||
|
garminConnected: userData.garminConnected,
|
||||||
|
lastPeriodDate: userData.lastPeriodDate,
|
||||||
|
notificationTime: userData.notificationTime,
|
||||||
|
}}
|
||||||
|
onSetPeriodDate={() => setShowPeriodModal(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -179,6 +229,7 @@ export default function Dashboard() {
|
|||||||
lastPeriodDate: userData.lastPeriodDate,
|
lastPeriodDate: userData.lastPeriodDate,
|
||||||
notificationTime: userData.notificationTime,
|
notificationTime: userData.notificationTime,
|
||||||
}}
|
}}
|
||||||
|
onSetPeriodDate={() => setShowPeriodModal(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cycle Info */}
|
{/* Cycle Info */}
|
||||||
@@ -231,6 +282,12 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<PeriodDateModal
|
||||||
|
isOpen={showPeriodModal}
|
||||||
|
onClose={() => setShowPeriodModal(false)}
|
||||||
|
onSubmit={handlePeriodDateSubmit}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
215
src/components/dashboard/period-date-modal.test.tsx
Normal file
215
src/components/dashboard/period-date-modal.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
158
src/components/dashboard/period-date-modal.tsx
Normal file
158
src/components/dashboard/period-date-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user