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", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user