Implement Dashboard page with real data integration (P1.7)
Wire up the Dashboard page with /api/today data: - Fetch today's decision, biometrics, and nutrition on mount - Display DecisionCard with status, icon, and reason - Show DataPanel with HRV, Body Battery, intensity minutes - Show NutritionPanel with seed cycling and carb guidance - Integrate OverrideToggles with POST/DELETE /api/overrides - Handle loading states, error states, and setup prompts - Display cycle day and phase information Add 23 unit tests for the Dashboard component covering: - Data fetching from /api/today and /api/user - Component rendering (DecisionCard, DataPanel, NutritionPanel) - Override toggle functionality (POST/DELETE API calls) - Error handling and loading states - Cycle information display Also fixed TypeScript error in login page tests (resolveAuth initialization). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
571
src/app/page.test.tsx
Normal file
571
src/app/page.test.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
// ABOUTME: Unit tests for the Dashboard page component.
|
||||
// ABOUTME: Tests data fetching, component rendering, override toggles, and error handling.
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
import Dashboard from "./page";
|
||||
|
||||
// Mock response data matching /api/today shape
|
||||
const mockTodayResponse = {
|
||||
decision: {
|
||||
status: "TRAIN",
|
||||
reason: "All systems go! Body battery and HRV look good.",
|
||||
icon: "💪",
|
||||
},
|
||||
cycleDay: 12,
|
||||
phase: "FOLLICULAR",
|
||||
phaseConfig: {
|
||||
name: "FOLLICULAR",
|
||||
days: [6, 13],
|
||||
weeklyLimit: 300,
|
||||
dailyAvg: 43,
|
||||
trainingType: "Build strength and endurance",
|
||||
},
|
||||
daysUntilNextPhase: 2,
|
||||
cycleLength: 31,
|
||||
biometrics: {
|
||||
hrvStatus: "Balanced",
|
||||
bodyBatteryCurrent: 75,
|
||||
bodyBatteryYesterdayLow: 45,
|
||||
weekIntensityMinutes: 120,
|
||||
phaseLimit: 300,
|
||||
},
|
||||
nutrition: {
|
||||
seeds: "Flax & Pumpkin seeds",
|
||||
carbRange: "100-150g",
|
||||
ketoGuidance: "Optional",
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserResponse = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
activeOverrides: [],
|
||||
cycleLength: 31,
|
||||
lastPeriodDate: "2024-01-01",
|
||||
};
|
||||
|
||||
describe("Dashboard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the PhaseFlow header", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
expect(screen.getByText("PhaseFlow")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the settings link", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
const settingsLink = screen.getByRole("link", { name: /settings/i });
|
||||
expect(settingsLink).toBeInTheDocument();
|
||||
expect(settingsLink).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("data fetching", () => {
|
||||
it("fetches data from /api/today on mount", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/today");
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches user data from /api/user on mount", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/user");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DecisionCard", () => {
|
||||
it("displays the decision status", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TRAIN")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays the decision icon", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("💪")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays the decision reason", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/all systems go/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataPanel", () => {
|
||||
it("displays body battery current", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/body battery.*75/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays HRV status", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/hrv.*balanced/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays week intensity and phase limit", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/120\/300 min/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NutritionPanel", () => {
|
||||
it("displays seed guidance", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/flax.*pumpkin/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays carb range", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/100-150g/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays keto guidance", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/keto.*optional/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OverrideToggles", () => {
|
||||
it("displays all override options", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
|
||||
expect(screen.getByText("High Stress")).toBeInTheDocument();
|
||||
expect(screen.getByText("Poor Sleep")).toBeInTheDocument();
|
||||
expect(screen.getByText("PMS Symptoms")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows active overrides as checked", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockUserResponse,
|
||||
activeOverrides: ["flare", "sleep"],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
const flareCheckbox = screen.getByRole("checkbox", {
|
||||
name: /flare mode/i,
|
||||
});
|
||||
const sleepCheckbox = screen.getByRole("checkbox", {
|
||||
name: /poor sleep/i,
|
||||
});
|
||||
const stressCheckbox = screen.getByRole("checkbox", {
|
||||
name: /high stress/i,
|
||||
});
|
||||
|
||||
expect(flareCheckbox).toBeChecked();
|
||||
expect(sleepCheckbox).toBeChecked();
|
||||
expect(stressCheckbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls POST /api/overrides when toggling on an override", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear mock to track new calls
|
||||
mockFetch.mockClear();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ activeOverrides: ["flare"] }),
|
||||
});
|
||||
|
||||
const flareCheckbox = screen.getByRole("checkbox", {
|
||||
name: /flare mode/i,
|
||||
});
|
||||
fireEvent.click(flareCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/overrides", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ override: "flare" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("calls DELETE /api/overrides when toggling off an override", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockUserResponse,
|
||||
activeOverrides: ["flare"],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
const flareCheckbox = screen.getByRole("checkbox", {
|
||||
name: /flare mode/i,
|
||||
});
|
||||
expect(flareCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
// Clear mock to track new calls
|
||||
mockFetch.mockClear();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ activeOverrides: [] }),
|
||||
});
|
||||
|
||||
const flareCheckbox = screen.getByRole("checkbox", {
|
||||
name: /flare mode/i,
|
||||
});
|
||||
fireEvent.click(flareCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/overrides", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ override: "flare" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches today data after toggle to update decision", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear mock and set up for toggle + refetch
|
||||
mockFetch.mockClear();
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ activeOverrides: ["flare"] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTodayResponse,
|
||||
decision: {
|
||||
status: "REST",
|
||||
reason: "Flare mode active - rest is essential.",
|
||||
icon: "🔥",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const flareCheckbox = screen.getByRole("checkbox", {
|
||||
name: /flare mode/i,
|
||||
});
|
||||
fireEvent.click(flareCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("REST")).toBeInTheDocument();
|
||||
expect(screen.getByText(/flare mode active/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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" }),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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.",
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
// Check for the specific help text about getting started
|
||||
expect(
|
||||
screen.getByText(/please log your period start date to get started/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle information", () => {
|
||||
it("displays current cycle day", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/day 12/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays current phase", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTodayResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUserResponse),
|
||||
});
|
||||
|
||||
render(<Dashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/follicular/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user