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

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

View File

@@ -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<string | null>(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 && <DashboardSkeleton />}
{error && (
<div role="alert" className="text-center py-12">
<p className="text-red-500">Error: {error}</p>
{error.includes("lastPeriodDate") && (
<p className="text-sm text-zinc-400 mt-2">
Please log your period start date to get started.
</p>
<div className="space-y-6">
<div role="alert" className="text-center py-12">
<p className="text-red-500">Error: {error}</p>
{error.includes("lastPeriodDate") && (
<p className="text-sm text-zinc-400 mt-2">
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>
)}
@@ -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() {
</div>
)}
</main>
<PeriodDateModal
isOpen={showPeriodModal}
onClose={() => setShowPeriodModal(false)}
onSubmit={handlePeriodDateSubmit}
/>
</div>
);
}