From 39198fdf8c213cd00ae409f7ea2d9a39f76a6a72 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sun, 11 Jan 2026 08:56:52 +0000 Subject: [PATCH] Implement Plan page with phase overview and exercise reference (P2.13) Add comprehensive training plan reference page that displays: - Current phase status (day, phase name, training type, weekly limit) - Phase overview cards for all 5 cycle phases with weekly intensity limits - Strength training exercises reference with sets and reps - Rebounding techniques organized by phase - Weekly training guidelines for each phase The page fetches cycle data from /api/cycle/current and highlights the current phase. Implements full TDD with 16 tests covering loading states, error handling, phase display, and exercise reference sections. Co-Authored-By: Claude Opus 4.5 --- IMPLEMENTATION_PLAN.md | 29 ++-- src/app/plan/page.test.tsx | 311 +++++++++++++++++++++++++++++++++++++ src/app/plan/page.tsx | 296 ++++++++++++++++++++++++++++++++++- 3 files changed, 619 insertions(+), 17 deletions(-) create mode 100644 src/app/plan/page.test.tsx diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 525f085..177622b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current State Summary -### Overall Status: 609 tests passing across 34 test files +### Overall Status: 625 tests passing across 35 test files ### Library Implementation | File | Status | Gap Analysis | @@ -61,7 +61,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Settings/Garmin (`/settings/garmin`) | **COMPLETE** | Token management UI, connection status, disconnect functionality, 27 tests | | Calendar (`/calendar`) | **COMPLETE** | MonthView with navigation, ICS subscription section, token regeneration, 23 tests | | History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests | -| Plan (`/plan`) | **NOT IMPLEMENTED** | Placeholder only - shows heading and placeholder text, needs phase details display | +| Plan (`/plan`) | **COMPLETE** | Phase overview, training guidelines, rebounding techniques, 16 tests | ### Components | Component | Status | Notes | @@ -116,6 +116,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/components/dashboard/override-toggles.test.tsx` | **MISSING** - Needs tests for toggle state and callbacks | | `src/components/dashboard/mini-calendar.test.tsx` | **EXISTS** - 23 tests (calendar grid, phase colors, navigation, legend) | | `src/components/calendar/day-cell.test.tsx` | **MISSING** - Needs tests for phase coloring, click handler | +| `src/app/plan/page.test.tsx` | **EXISTS** - 16 tests (loading states, error handling, phase display, exercise reference, rebounding techniques) | | E2E tests | **AUTHORIZED SKIP** - Per specs/testing.md | ### Critical Business Rules (from Spec) @@ -447,18 +448,18 @@ Full feature set for production use. - **Why:** Users want to review their training history - **Depends On:** P2.8 -### P2.13: Plan Page Implementation -- [ ] Phase-specific training plan view -- **Current State:** Placeholder only - shows "Exercise Plan" heading and placeholder text, no actual content +### P2.13: Plan Page Implementation ✅ COMPLETE +- [x] Phase-specific training plan view - **Files:** - - `src/app/plan/page.tsx` - Current phase details, upcoming phases, limits + - `src/app/plan/page.tsx` - Phase overview, training guidelines, exercise reference, rebounding techniques - **Tests:** - - Component tests: phase info display, limits shown correctly -- **Features Needed:** - - Current phase details (name, day range, characteristics) - - Upcoming phases preview - - Phase-specific training limits and recommendations - - Integration with cycle.ts utilities + - `src/app/plan/page.test.tsx` - 16 tests covering loading states, error handling, phase display, exercise reference, rebounding techniques +- **Features Implemented:** + - Current phase status display (day, phase name, training type, weekly limit) + - Phase overview cards for all 5 phases with weekly intensity minute limits + - Strength training exercises reference with descriptions + - Rebounding techniques organized by phase (follicular and luteal) + - Weekly guidelines for each phase with training goals - **Why:** Users want detailed training guidance - **Depends On:** P0.4, P1.3 @@ -795,7 +796,6 @@ P4.* UX Polish ────────> After core functionality complete | Priority | Task | Effort | Notes | |----------|------|--------|-------| -| Medium | P2.13 Plan Page | Medium | Placeholder exists, needs content | | Medium | P2.18 OIDC Auth | Large | Production auth requirement | | Medium | P3.11 Component Tests | Medium | 5 components need tests | | Low | P3.7 Error Handling | Small | Polish | @@ -860,13 +860,14 @@ P4.* UX Polish ────────> After core functionality complete - [x] **GET /api/health** - Health check endpoint with PocketBase connectivity check, 14 tests (P2.15) - [x] **GET /metrics** - Prometheus metrics endpoint with counters, gauges, histograms, 33 tests (18 lib + 15 route) (P2.16) -### Pages (6 complete, 1 placeholder) +### Pages (7 complete) - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) - [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7) - [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9) - [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10) - [x] **Calendar Page** - MonthView with navigation controls, ICS subscription section with URL display, copy button, token regeneration, 23 tests (P2.11) - [x] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12) +- [x] **Plan Page** - Phase overview, training guidance, exercise reference, rebounding techniques, 16 tests (P2.13) ### Test Infrastructure - [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup diff --git a/src/app/plan/page.test.tsx b/src/app/plan/page.test.tsx new file mode 100644 index 0000000..908d5b7 --- /dev/null +++ b/src/app/plan/page.test.tsx @@ -0,0 +1,311 @@ +// ABOUTME: Unit tests for the Plan page component. +// ABOUTME: Tests phase display, training guidance, and exercise reference content. +import { 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 PlanPage from "./page"; + +// Mock response data matching /api/cycle/current shape +const mockCycleResponse = { + cycleDay: 12, + phase: "FOLLICULAR", + phaseConfig: { + name: "FOLLICULAR", + days: [4, 14], + weeklyLimit: 120, + dailyAvg: 17, + trainingType: "Strength + rebounding", + }, + daysUntilNextPhase: 3, + cycleLength: 31, +}; + +describe("PlanPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("loading and error states", () => { + it("shows loading state initially", () => { + mockFetch.mockImplementation(() => new Promise(() => {})); + render(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("shows error state when fetch fails", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: "Failed to fetch cycle data" }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument(); + }); + }); + + describe("page header", () => { + it("renders the Exercise Plan heading", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + "Exercise Plan", + ); + }); + }); + }); + + describe("current phase section", () => { + it("displays current phase name in status", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + // Current status section shows "Day X · PHASE_NAME" + expect(screen.getByText(/day 12 · follicular/i)).toBeInTheDocument(); + }); + }); + + it("displays current cycle day", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/day 12/i)).toBeInTheDocument(); + }); + }); + + it("displays days until next phase", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/3 days until/i)).toBeInTheDocument(); + }); + }); + + it("displays current phase training type", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + // Training type label and value are present (multiple instances OK) + expect(screen.getByText("Training type:")).toBeInTheDocument(); + expect( + screen.getAllByText("Strength + rebounding").length, + ).toBeGreaterThan(0); + }); + }); + + it("displays weekly limit for current phase", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + // Weekly limit label and value are present (multiple instances OK) + expect(screen.getByText("Weekly limit:")).toBeInTheDocument(); + expect(screen.getAllByText(/120 min\/week/).length).toBeGreaterThan(0); + }); + }); + }); + + describe("phase overview section", () => { + it("displays all five phases", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + // Check for phase cards by testid + expect(screen.getByTestId("phase-MENSTRUAL")).toBeInTheDocument(); + expect(screen.getByTestId("phase-FOLLICULAR")).toBeInTheDocument(); + expect(screen.getByTestId("phase-OVULATION")).toBeInTheDocument(); + expect(screen.getByTestId("phase-EARLY_LUTEAL")).toBeInTheDocument(); + expect(screen.getByTestId("phase-LATE_LUTEAL")).toBeInTheDocument(); + }); + }); + + it("displays weekly limits for each phase", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + // Phase limits appear in the phase cards as "X min/week" + const menstrualCard = screen.getByTestId("phase-MENSTRUAL"); + const follicularCard = screen.getByTestId("phase-FOLLICULAR"); + const ovulationCard = screen.getByTestId("phase-OVULATION"); + const earlyLutealCard = screen.getByTestId("phase-EARLY_LUTEAL"); + const lateLutealCard = screen.getByTestId("phase-LATE_LUTEAL"); + + expect(menstrualCard).toHaveTextContent("30 min/week"); + expect(follicularCard).toHaveTextContent("120 min/week"); + expect(ovulationCard).toHaveTextContent("80 min/week"); + expect(earlyLutealCard).toHaveTextContent("100 min/week"); + expect(lateLutealCard).toHaveTextContent("50 min/week"); + }); + }); + + it("highlights the current phase", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + const currentPhaseCard = screen.getByTestId("phase-FOLLICULAR"); + expect(currentPhaseCard).toHaveClass("ring-2"); + }); + }); + }); + + describe("exercise reference section", () => { + it("displays strength training exercises", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/squats/i)).toBeInTheDocument(); + expect(screen.getByText(/push-ups/i)).toBeInTheDocument(); + expect(screen.getByText(/deadlifts/i)).toBeInTheDocument(); + expect(screen.getByText(/plank/i)).toBeInTheDocument(); + expect(screen.getByText(/kettlebell/i)).toBeInTheDocument(); + }); + }); + + it("displays rebounding techniques section", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/rebounding techniques/i)).toBeInTheDocument(); + }); + }); + + it("displays phase-specific rebounding guidance", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockCycleResponse), + }); + + render(); + + await waitFor(() => { + // Rebounding techniques section contains different techniques per phase + expect( + screen.getByText(/health bounce, lymphatic drainage/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/maximum intensity, plyometric/i), + ).toBeInTheDocument(); + }); + }); + }); + + describe("different phases", () => { + it("shows menstrual phase correctly when current", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockCycleResponse, + cycleDay: 2, + phase: "MENSTRUAL", + phaseConfig: { + name: "MENSTRUAL", + days: [1, 3], + weeklyLimit: 30, + dailyAvg: 10, + trainingType: "Gentle rebounding only", + }, + daysUntilNextPhase: 2, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/day 2/i)).toBeInTheDocument(); + const currentPhaseCard = screen.getByTestId("phase-MENSTRUAL"); + expect(currentPhaseCard).toHaveClass("ring-2"); + }); + }); + + it("shows late luteal phase correctly when current", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + ...mockCycleResponse, + cycleDay: 28, + phase: "LATE_LUTEAL", + phaseConfig: { + name: "LATE_LUTEAL", + days: [25, 31], + weeklyLimit: 50, + dailyAvg: 8, + trainingType: "Gentle rebounding ONLY", + }, + daysUntilNextPhase: 4, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/day 28/i)).toBeInTheDocument(); + const currentPhaseCard = screen.getByTestId("phase-LATE_LUTEAL"); + expect(currentPhaseCard).toHaveClass("ring-2"); + }); + }); + }); +}); diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 97e55dc..08717db 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -1,11 +1,301 @@ // ABOUTME: Exercise plan reference page. -// ABOUTME: Displays the full monthly exercise plan by phase. +// ABOUTME: Displays the full monthly exercise plan by phase with current phase highlighted. +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { CyclePhase, PhaseConfig } from "@/types"; + +interface CycleData { + cycleDay: number; + phase: CyclePhase; + phaseConfig: PhaseConfig; + daysUntilNextPhase: number; + cycleLength: number; +} + +// Phase configurations for display +const PHASES: Array<{ + name: CyclePhase; + displayName: string; + weeklyLimit: number; + trainingType: string; + description: string; +}> = [ + { + name: "MENSTRUAL", + displayName: "Menstrual", + weeklyLimit: 30, + trainingType: "Gentle rebounding only", + description: "Focus on rest and gentle movement. Light lymphatic drainage.", + }, + { + name: "FOLLICULAR", + displayName: "Follicular", + weeklyLimit: 120, + trainingType: "Strength + rebounding", + description: + "Building phase - increase intensity and add strength training.", + }, + { + name: "OVULATION", + displayName: "Ovulation", + weeklyLimit: 80, + trainingType: "Peak performance", + description: "Peak energy - maximize intensity and plyometric movements.", + }, + { + name: "EARLY_LUTEAL", + displayName: "Early Luteal", + weeklyLimit: 100, + trainingType: "Moderate training", + description: + "Maintain intensity but listen to your body for signs of fatigue.", + }, + { + name: "LATE_LUTEAL", + displayName: "Late Luteal", + weeklyLimit: 50, + trainingType: "Gentle rebounding ONLY", + description: + "Wind down phase - focus on stress relief and gentle movement.", + }, +]; + +const STRENGTH_EXERCISES = [ + { name: "Squats", sets: "3x8-12" }, + { name: "Push-ups", sets: "3x5-10" }, + { name: "Single-leg Deadlifts", sets: "3x6-8 each" }, + { name: "Plank", sets: "3x20-45s" }, + { name: "Kettlebell Swings", sets: "2x10-15" }, +]; + +const REBOUNDING_TECHNIQUES = [ + { + phase: "Menstrual", + techniques: "Health bounce, lymphatic drainage", + }, + { + phase: "Follicular", + techniques: "Strength bounce, intervals", + }, + { + phase: "Ovulation", + techniques: "Maximum intensity, plyometric", + }, + { + phase: "Luteal", + techniques: "Therapeutic, stress relief", + }, +]; + export default function PlanPage() { + const [cycleData, setCycleData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCycleData = useCallback(async () => { + const response = await fetch("/api/cycle/current"); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to fetch cycle data"); + } + + return data as CycleData; + }, []); + + useEffect(() => { + async function loadData() { + try { + setLoading(true); + setError(null); + const data = await fetchCycleData(); + setCycleData(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setLoading(false); + } + } + + loadData(); + }, [fetchCycleData]); + + if (loading) { + return ( +
+

Exercise Plan

+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

Exercise Plan

+
+ Error: {error} +
+
+ ); + } + return (

Exercise Plan

- {/* Exercise plan content will be implemented here */} -

Exercise plan placeholder

+ + {cycleData && ( +
+ {/* Current Phase Status */} +
+

Current Status

+

+ Day {cycleData.cycleDay} · {cycleData.phase.replace("_", " ")} +

+

+ {cycleData.daysUntilNextPhase} days until next phase +

+

+ Training type:{" "} + {cycleData.phaseConfig.trainingType} +

+

+ Weekly limit:{" "} + {cycleData.phaseConfig.weeklyLimit} min/week +

+
+ + {/* Phase Overview */} +
+

Phase Overview

+
+ {PHASES.map((phase) => ( +
+

+ {phase.displayName} +

+

+ {phase.trainingType} +

+

+ {phase.weeklyLimit} min/week +

+

+ {phase.description} +

+
+ ))} +
+
+ + {/* Strength Training Reference */} +
+

+ Strength Training (Follicular Phase) +

+

+ Mon/Wed/Fri during follicular phase (20-25 min per session) +

+
+ + + + + + + + + {STRENGTH_EXERCISES.map((exercise) => ( + + + + + ))} + +
ExerciseSets × Reps
{exercise.name} + {exercise.sets} +
+
+
+ + {/* Rebounding Techniques */} +
+

+ Rebounding Techniques +

+

+ Adjust your rebounding style based on your current phase +

+
+ {REBOUNDING_TECHNIQUES.map((item) => ( +
+

{item.phase}

+

+ {item.techniques} +

+
+ ))} +
+
+ + {/* Weekly Schedule Reference */} +
+

Weekly Guidelines

+
+
+

Menstrual Phase (Days 1-3)

+
    +
  • Morning: 10-15 min gentle rebounding
  • +
  • Evening: 15-20 min restorative movement
  • +
  • No strength training
  • +
+
+ +
+

Follicular Phase (Days 4-14)

+
    +
  • Mon/Wed/Fri: Strength training (20-25 min)
  • +
  • Tue/Thu: Active recovery rebounding (20 min)
  • +
  • Weekend: Choose your adventure
  • +
+
+ +
+

+ Ovulation + Early Luteal (Days 15-24) +

+
    +
  • Days 15-16: Peak performance (25-30 min strength)
  • +
  • + Days 17-21: Modified strength (reduce intensity 10-20%) +
  • +
+
+ +
+

Late Luteal Phase (Days 22-28)

+
    +
  • Daily: Gentle rebounding only (15-20 min)
  • +
  • Optional light bodyweight Mon/Wed if feeling good
  • +
  • Rest days: Tue/Thu/Sat/Sun
  • +
+
+
+
+
+ )}
); }