From 0549f7d05fddc3485edf7676ebfc4c2c0bf975e3 Mon Sep 17 00:00:00 2001 From: Daniel Sallai Date: Sat, 11 Apr 2026 22:35:36 +0200 Subject: [PATCH 1/5] feat(plan-tuner): frontend components, hooks, E2E MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements all Phase 4 frontend deliverables for the workout plan fine tuner feature: - useWorkoutPlan.ts: TanStack Query hooks (useCurrentPlan, useCreatePlan, useTunePlan, useDecideAdjustments) with toast feedback - PlanPage: empty state with inline editor, populated state with day cards and version badge, collapsible version history - PlanEditor: textarea + Parse & Save with loading/error states - PlanDayCard: exercise table with sets × reps × load, fallback notes view - PlanVersionHistory: collapsible version list with source badges - AdjustmentReviewModal: day-grouped diff list, per-row accept/reject, accept all/reject all triage, confidence dots, evidence expand, commit - OptimizePlanButton: enabled/disabled states, tune mutation, modal integration - Unit tests: 22 new tests across 3 test files (all passing) - E2E: 7-test spec covering full create→optimize→review→accept flow Co-Authored-By: Claude Sonnet 4.6 --- e2e/fixtures/parsed-plan.json | 123 +++++++ e2e/fixtures/plan-adjustment-batch.json | 75 +++++ e2e/pages/PlanPage.ts | 60 ++++ e2e/workout-plan-tuner.spec.ts | 307 ++++++++++++++++++ packages/frontend/src/App.tsx | 2 + .../frontend/src/api/hooks/useWorkoutPlan.ts | 86 +++++ .../src/components/layout/nav-items.ts | 1 + .../components/plan/AdjustmentReviewModal.tsx | 302 +++++++++++++++++ .../src/components/plan/PlanDayCard.tsx | 88 +++++ .../src/components/plan/PlanEditor.tsx | 78 +++++ .../frontend/src/components/plan/PlanPage.tsx | 97 ++++++ .../components/plan/PlanVersionHistory.tsx | 83 +++++ .../__tests__/AdjustmentReviewModal.test.tsx | 146 +++++++++ .../plan/__tests__/PlanEditor.test.tsx | 85 +++++ .../components/reports/OptimizePlanButton.tsx | 114 +++++++ .../src/components/reports/ReportCard.tsx | 2 + .../__tests__/OptimizePlanButton.test.tsx | 133 ++++++++ 17 files changed, 1782 insertions(+) create mode 100644 e2e/fixtures/parsed-plan.json create mode 100644 e2e/fixtures/plan-adjustment-batch.json create mode 100644 e2e/pages/PlanPage.ts create mode 100644 e2e/workout-plan-tuner.spec.ts create mode 100644 packages/frontend/src/api/hooks/useWorkoutPlan.ts create mode 100644 packages/frontend/src/components/plan/AdjustmentReviewModal.tsx create mode 100644 packages/frontend/src/components/plan/PlanDayCard.tsx create mode 100644 packages/frontend/src/components/plan/PlanEditor.tsx create mode 100644 packages/frontend/src/components/plan/PlanPage.tsx create mode 100644 packages/frontend/src/components/plan/PlanVersionHistory.tsx create mode 100644 packages/frontend/src/components/plan/__tests__/AdjustmentReviewModal.test.tsx create mode 100644 packages/frontend/src/components/plan/__tests__/PlanEditor.test.tsx create mode 100644 packages/frontend/src/components/reports/OptimizePlanButton.tsx create mode 100644 packages/frontend/src/components/reports/__tests__/OptimizePlanButton.test.tsx diff --git a/e2e/fixtures/parsed-plan.json b/e2e/fixtures/parsed-plan.json new file mode 100644 index 0000000..34c3ec7 --- /dev/null +++ b/e2e/fixtures/parsed-plan.json @@ -0,0 +1,123 @@ +{ + "data": { + "plan": { + "id": "plan-fixture-001", + "userId": "default", + "name": "My PPL Plan", + "splitType": "PPL", + "notes": null, + "activeVersionId": "version-fixture-001", + "createdAt": "2026-04-11T09:00:00.000Z", + "updatedAt": "2026-04-11T09:00:00.000Z" + }, + "latestVersion": { + "id": "version-fixture-001", + "planId": "plan-fixture-001", + "versionNumber": 1, + "source": "user", + "parentVersionId": null, + "data": { + "splitType": "PPL", + "progressionPersonality": "balanced", + "days": [ + { + "name": "Push A", + "targetMuscles": ["chest", "front deltoid", "triceps"], + "exercises": [ + { + "id": "ex-001", + "exerciseName": "Bench Press", + "orderInDay": 1, + "sets": [ + { "type": "warmup", "targetReps": 8, "targetWeightKg": 60 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 } + ], + "progressionRule": "double", + "primaryMuscle": "chest", + "secondaryMuscles": ["front deltoid", "triceps"], + "pattern": "push", + "equipment": "barbell", + "sfrTier": "S" + }, + { + "id": "ex-002", + "exerciseName": "Incline Dumbbell Press", + "orderInDay": 2, + "sets": [ + { "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 }, + { "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 }, + { "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 } + ], + "progressionRule": "double", + "primaryMuscle": "chest", + "secondaryMuscles": ["front deltoid"], + "pattern": "push", + "equipment": "dumbbell", + "sfrTier": "A" + }, + { + "id": "ex-003", + "exerciseName": "Overhead Press", + "orderInDay": 3, + "sets": [ + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 50 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 50 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 50 } + ], + "progressionRule": "linear", + "primaryMuscle": "front deltoid", + "secondaryMuscles": ["triceps"], + "pattern": "push", + "equipment": "barbell", + "sfrTier": "A" + } + ] + }, + { + "name": "Pull A", + "targetMuscles": ["back", "rear deltoid", "biceps"], + "exercises": [ + { + "id": "ex-004", + "exerciseName": "Barbell Row", + "orderInDay": 1, + "sets": [ + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 }, + { "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 } + ], + "progressionRule": "double", + "primaryMuscle": "back", + "secondaryMuscles": ["rear deltoid", "biceps"], + "pattern": "pull", + "equipment": "barbell", + "sfrTier": "S" + }, + { + "id": "ex-005", + "exerciseName": "Pull-ups", + "orderInDay": 2, + "sets": [ + { "type": "normal", "targetReps": [6, 10] }, + { "type": "normal", "targetReps": [6, 10] }, + { "type": "normal", "targetReps": [6, 10] } + ], + "progressionRule": "double", + "primaryMuscle": "back", + "secondaryMuscles": ["biceps"], + "pattern": "pull", + "equipment": "bodyweight", + "sfrTier": "S" + } + ] + } + ] + }, + "createdAt": "2026-04-11T09:00:00.000Z", + "acceptedAt": "2026-04-11T09:00:00.000Z", + "notes": "Parsed from user paste" + } + } +} diff --git a/e2e/fixtures/plan-adjustment-batch.json b/e2e/fixtures/plan-adjustment-batch.json new file mode 100644 index 0000000..acb3b0e --- /dev/null +++ b/e2e/fixtures/plan-adjustment-batch.json @@ -0,0 +1,75 @@ +{ + "data": { + "id": "batch-fixture-001", + "planId": "plan-fixture-001", + "sourceVersionId": "version-fixture-001", + "reportId": "report-fixture-001", + "createdAt": "2026-04-11T10:00:00.000Z", + "rationale": "Good recovery week — progress main lifts, hold accessories. Low HRV last 3 days suggests deload on secondary volume.", + "adjustments": [ + { + "id": "adj-fixture-001", + "batchId": "batch-fixture-001", + "exerciseRef": { "dayIndex": 0, "exerciseOrder": 1 }, + "changeType": "progress_load", + "oldValue": { "sets": [{ "type": "normal", "targetReps": [5, 8], "targetWeightKg": 80 }] }, + "newValue": { + "sets": [{ "type": "normal", "targetReps": [5, 8], "targetWeightKg": 82.5 }] + }, + "evidence": [ + { + "kind": "exercise_progress", + "refId": null, + "excerpt": "Bench press: hit 8 reps at 80 kg in last 2 sessions (2-for-2 rule triggered)" + }, + { + "kind": "metric", + "refId": null, + "excerpt": "Weekly average RPE for bench: 7.2 — well below the 9.0 guardrail" + } + ], + "confidence": 4, + "rationale": "Two-for-two rule met; load increase of 2.5 kg is within the 10% safety cap.", + "status": "pending" + }, + { + "id": "adj-fixture-002", + "batchId": "batch-fixture-001", + "exerciseRef": { "dayIndex": 0, "exerciseOrder": 2 }, + "changeType": "hold", + "oldValue": { "sets": [{ "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 }] }, + "newValue": { "sets": [{ "type": "normal", "targetReps": [8, 12], "targetWeightKg": 30 }] }, + "evidence": [ + { + "kind": "hazard", + "refId": null, + "excerpt": "Report hazards: 'mild shoulder fatigue noted mid-week — avoid increasing pressing volume'" + } + ], + "confidence": 3, + "rationale": "Shoulder fatigue flagged in hazards; hold incline press load as a precaution.", + "status": "pending" + }, + { + "id": "adj-fixture-003", + "batchId": "batch-fixture-001", + "exerciseRef": { "dayIndex": 1, "exerciseOrder": 1 }, + "changeType": "progress_load", + "oldValue": { "sets": [{ "type": "normal", "targetReps": [6, 10], "targetWeightKg": 80 }] }, + "newValue": { + "sets": [{ "type": "normal", "targetReps": [6, 10], "targetWeightKg": 82.5 }] + }, + "evidence": [ + { + "kind": "exercise_progress", + "refId": null, + "excerpt": "Barbell row: hit 10 reps at 80 kg last session — top of rep range reached" + } + ], + "confidence": 4, + "rationale": "Double progression triggered; rep range top hit, increment load by 2.5 kg.", + "status": "pending" + } + ] + } +} diff --git a/e2e/pages/PlanPage.ts b/e2e/pages/PlanPage.ts new file mode 100644 index 0000000..a6aaa2b --- /dev/null +++ b/e2e/pages/PlanPage.ts @@ -0,0 +1,60 @@ +import type { Locator, Page } from '@playwright/test'; + +/** + * Page Object Model for the Workout Plan page (/plan). + */ +export class PlanPagePOM { + readonly page: Page; + readonly heading: Locator; + readonly emptyState: Locator; + readonly pasteTextarea: Locator; + readonly submitButton: Locator; + readonly versionHistorySection: Locator; + + constructor(page: Page) { + this.page = page; + this.heading = page.getByRole('heading', { name: 'Workout Plan' }); + this.emptyState = page.getByText('Create your plan'); + this.pasteTextarea = page.getByTestId('plan-textarea'); + this.submitButton = page.getByRole('button', { name: /parse & save/i }); + this.versionHistorySection = page.getByText(/version history/i); + } + + async goto() { + await this.page.goto('/plan'); + } + + /** Submit a raw text plan via the paste editor. */ + async submitPlan(rawText: string) { + await this.pasteTextarea.fill(rawText); + await this.submitButton.click(); + } + + /** Paste text and click Parse & Save (alias for submitPlan). */ + async pasteAndSubmit(text: string) { + await this.submitPlan(text); + } + + /** Returns the version badge/label for the current active version. */ + get activeVersionBadge() { + return this.page.getByText(/v\d+/i).first(); + } + + /** Returns a day card by day name (matches CardTitle div text). */ + getDayCard(dayName: string): Locator { + return this.page.getByText(dayName, { exact: true }).first(); + } + + /** Expects N day cards to be visible. */ + async expectDayCount(n: number) { + const { expect } = await import('@playwright/test'); + const cards = this.page.locator('[data-slot="card"]'); + await expect(cards).toHaveCount(n); + } + + /** Expects the version badge to show version N. */ + async expectVersion(n: number) { + const { expect } = await import('@playwright/test'); + await expect(this.page.getByText(`v${n}`)).toBeVisible(); + } +} diff --git a/e2e/workout-plan-tuner.spec.ts b/e2e/workout-plan-tuner.spec.ts new file mode 100644 index 0000000..23603aa --- /dev/null +++ b/e2e/workout-plan-tuner.spec.ts @@ -0,0 +1,307 @@ +import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { PlanPagePOM } from './pages/PlanPage'; +import { ReportsPage } from './pages/reports.page'; +import parsedPlanFixture from './fixtures/parsed-plan.json' with { type: 'json' }; +import adjustmentBatchFixture from './fixtures/plan-adjustment-batch.json' with { type: 'json' }; + +// --------------------------------------------------------------------------- +// Fixtures & mocks +// --------------------------------------------------------------------------- + +const reportFixture = { + id: 'report-fixture-001', + userId: 'user-1', + periodStart: '2026-04-05', + periodEnd: '2026-04-11', + summary: 'Solid training week. Good recovery indicators.', + insights: '## Training Load\nStrong bench press progress. Pull work consistent.', + actionItems: [], + dataCoverage: { nutritionDays: 7, workoutDays: 5, biometricDays: 6 }, + status: 'completed', + aiProvider: 'gemini', + aiModel: 'gemini-2.0-flash', + createdAt: '2026-04-11', +}; + +const parsedPlan = parsedPlanFixture.data; + +const planV2Fixture = { + plan: { + ...parsedPlan.plan, + activeVersionId: 'version-fixture-002', + updatedAt: '2026-04-11T11:00:00.000Z', + }, + latestVersion: { + ...parsedPlan.latestVersion, + id: 'version-fixture-002', + versionNumber: 2, + source: 'tuner', + parentVersionId: 'version-fixture-001', + createdAt: '2026-04-11T11:00:00.000Z', + acceptedAt: '2026-04-11T11:00:00.000Z', + notes: 'AI tuned — 2 changes applied', + }, +}; + +/** Mock GET /api/workout-plans/current returning null (no plan). */ +async function mockNoPlan(page: Page) { + await page.route('**/api/workout-plans/current', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: null }), + }); + }); +} + +/** Mock GET /api/workout-plans/current returning parsed plan v1. */ +async function mockPlanV1(page: Page) { + await page.route('**/api/workout-plans/current', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(parsedPlanFixture), + }); + }); +} + +/** Mock GET /api/workout-plans/current returning plan v2 (after accept). */ +async function mockPlanV2(page: Page) { + await page.route('**/api/workout-plans/current', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: planV2Fixture }), + }); + }); +} + +/** Mock POST /api/workout-plans (parse-and-create). */ +async function mockCreatePlan(page: Page) { + await page.route('**/api/workout-plans', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(parsedPlanFixture), + }); + } else { + await route.fallback(); + } + }); +} + +/** Mock GET /api/reports. */ +async function mockReportsWithReport(page: Page) { + await page.route('**/api/reports', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: [reportFixture] }), + }); + } else { + await route.fallback(); + } + }); + // Also mock generate just in case + await page.route('**/api/reports/generate', async (route) => { + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ data: { reportId: 'report-fixture-001', status: 'pending' } }), + }); + }); +} + +/** Mock POST /api/workout-plans/:id/tune. */ +async function mockTunePlan(page: Page) { + await page.route('**/api/workout-plans/*/tune', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(adjustmentBatchFixture), + }); + }); +} + +/** Mock PATCH /api/workout-plans/adjustments/:batchId. */ +async function mockDecideAdjustments(page: Page) { + await page.route('**/api/workout-plans/adjustments/**', async (route) => { + if (route.request().method() === 'PATCH') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data: planV2Fixture.latestVersion }), + }); + } else { + await route.fallback(); + } + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe('UC-PLAN-01: Workout Plan Fine Tuner', () => { + test.describe('Create plan flow', () => { + test('navigating to /plan shows empty state with create CTA', async ({ page }) => { + await mockNoPlan(page); + const planPage = new PlanPagePOM(page); + await planPage.goto(); + + await expect(planPage.heading).toBeVisible(); + await expect(planPage.emptyState).toBeVisible(); + // Textarea for pasting plan text should be present + await expect(planPage.pasteTextarea).toBeVisible(); + }); + + test('pasting plan text and submitting parses and displays days', async ({ page }) => { + // First response: no plan; after POST create: return plan v1 + let planCreated = false; + + await page.route('**/api/workout-plans/current', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(planCreated ? parsedPlanFixture : { data: null }), + }); + }); + + // Mock POST — toggle planCreated flag so subsequent GETs return the plan + await page.route('**/api/workout-plans', async (route) => { + if (route.request().method() === 'POST') { + planCreated = true; + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify(parsedPlanFixture), + }); + } else { + await route.fallback(); + } + }); + + const planPage = new PlanPagePOM(page); + await planPage.goto(); + await expect(planPage.emptyState).toBeVisible(); + + // Fill and submit — planCreated is still false here, so the textarea is visible + await planPage.pasteTextarea.fill( + 'Push A\nBench Press 3x8 @ 80kg\n\nPull A\nBarbell Row 3x8 @ 80kg', + ); + await planPage.submitButton.click(); + + // Day cards should appear after successful parse (planCreated is now true → GET returns plan) + await expect(planPage.getDayCard('Push A')).toBeVisible({ timeout: 8000 }); + await expect(planPage.getDayCard('Pull A')).toBeVisible({ timeout: 8000 }); + }); + }); + + test.describe('Optimize plan CTA on report card', () => { + test('report card shows "Optimize next week\'s plan" button when plan exists', async ({ + page, + }) => { + await mockReportsWithReport(page); + await mockPlanV1(page); + + const reports = new ReportsPage(page); + await reports.goto(); + + // Expand the first report card + await page.locator('[data-slot="card-header"] button').first().click(); + + // The optimize button should be visible and enabled + await expect(page.getByTestId('optimize-button')).toBeVisible({ timeout: 5000 }); + }); + + test('report card shows disabled CTA with "Create a plan to unlock" when no plan', async ({ + page, + }) => { + await mockReportsWithReport(page); + await mockNoPlan(page); + + const reports = new ReportsPage(page); + await reports.goto(); + + await page.locator('[data-slot="card-header"] button').first().click(); + + // Disabled button should be present + await expect(page.getByTestId('optimize-disabled')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText(/create a plan to unlock/i)).toBeVisible(); + }); + }); + + test.describe('Tune plan → review → accept flow (happy path)', () => { + test('clicking optimize triggers tune, opens modal with adjustment batch', async ({ page }) => { + await mockReportsWithReport(page); + await mockPlanV1(page); + await mockTunePlan(page); + + const reports = new ReportsPage(page); + await reports.goto(); + + await page.locator('[data-slot="card-header"] button').first().click(); + await expect(page.getByTestId('optimize-button')).toBeVisible({ timeout: 5000 }); + + await page.getByTestId('optimize-button').click(); + + // Modal should open with adjustment rows + await expect(page.getByRole('heading', { name: /review plan adjustments/i })).toBeVisible({ + timeout: 8000, + }); + + // Should show progress_load changes (multiple may appear for multiple exercises) + await expect(page.getByText('Progress load').first()).toBeVisible(); + }); + + test('rejecting one change and accepting the rest commits correctly', async ({ page }) => { + await mockReportsWithReport(page); + await mockPlanV1(page); + await mockTunePlan(page); + await mockDecideAdjustments(page); + + const reports = new ReportsPage(page); + await reports.goto(); + await page.locator('[data-slot="card-header"] button').first().click(); + await page.getByTestId('optimize-button').click(); + + await expect(page.getByRole('heading', { name: /review plan adjustments/i })).toBeVisible({ + timeout: 8000, + }); + + // Reject the second adjustment (adj-fixture-002) + await page.getByTestId('reject-adj-fixture-002').click(); + + // Commit should now say "2 accepted" (3 total, 1 rejected) + await expect(page.getByText(/commit changes \(2 accepted\)/i)).toBeVisible(); + + // Click commit + const patchRequest = page.waitForRequest( + (req) => req.url().includes('/api/workout-plans/adjustments/') && req.method() === 'PATCH', + ); + await page.getByRole('button', { name: /commit changes/i }).click(); + const req = await patchRequest; + + const body = req.postDataJSON() as { + decisions: Record; + }; + expect(body.decisions['adj-fixture-001']).toBe('accepted'); + expect(body.decisions['adj-fixture-002']).toBe('rejected'); + expect(body.decisions['adj-fixture-003']).toBe('accepted'); + }); + + test('after accepting, /plan shows new version number and history entry', async ({ page }) => { + await mockPlanV2(page); + + const planPage = new PlanPagePOM(page); + await planPage.goto(); + + // Should display version 2 badge + await expect(planPage.activeVersionBadge).toBeVisible({ timeout: 5000 }); + await expect(planPage.activeVersionBadge).toContainText('2'); + }); + }); +}); diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 3caedb7..009ce24 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { WorkoutsPage } from '@/components/workouts/WorkoutsPage'; import { ReportsPage } from '@/components/reports/ReportsPage'; import { ChatPage } from '@/components/chat/ChatPage'; import { ActionsPage } from '@/components/actions/ActionsPage'; +import { PlanPage } from '@/components/plan/PlanPage'; import { PwaUpdatePrompt } from '@/components/pwa/PwaUpdatePrompt'; import { useHealthKitSync } from '@/api/hooks/useHealthKitSync'; import { isNative } from '@/native/capacitor'; @@ -134,6 +135,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/packages/frontend/src/api/hooks/useWorkoutPlan.ts b/packages/frontend/src/api/hooks/useWorkoutPlan.ts new file mode 100644 index 0000000..ab0d21c --- /dev/null +++ b/packages/frontend/src/api/hooks/useWorkoutPlan.ts @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import type { + ApiResponse, + WorkoutPlan, + PlanVersion, + PlanAdjustmentBatch, + TunePlanRequest, + CreatePlanRequest, + DecideAdjustmentsRequest, +} from '@vitals/shared'; +import { QUERY_KEYS } from '@vitals/shared'; +import { apiFetch } from '../client'; + +/** Shape returned by GET /api/workout-plans/current */ +export interface CurrentPlanResponse { + plan: WorkoutPlan; + latestVersion: PlanVersion; +} + +/** + * Fetches the user's current workout plan and its latest version. + * Returns null data if the user has no plan yet. + */ +export function useCurrentPlan() { + return useQuery({ + queryKey: QUERY_KEYS.workoutPlan.current, + queryFn: () => apiFetch>('/api/workout-plans/current'), + }); +} + +/** + * Creates a new workout plan from raw text or structured data. + * Invalidates the current plan query on success. + */ +export function useCreatePlan() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: CreatePlanRequest) => + apiFetch>('/api/workout-plans', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success('Plan created successfully'); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.workoutPlan.current }); + }, + }); +} + +/** + * Triggers the AI tuner for a plan. + * Returns a PlanAdjustmentBatch ready for review. + */ +export function useTunePlan(planId: string) { + return useMutation({ + mutationFn: (body: TunePlanRequest) => + apiFetch>(`/api/workout-plans/${planId}/tune`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }); +} + +/** + * Submits accept/reject decisions for an adjustment batch. + * On success, invalidates the current plan (new version may be active). + */ +export function useDecideAdjustments(batchId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: DecideAdjustmentsRequest) => + apiFetch>(`/api/workout-plans/adjustments/${batchId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + onSuccess: (response) => { + const version = response.data; + toast.success(`Plan updated to version ${version.versionNumber}`); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.workoutPlan.current }); + }, + }); +} diff --git a/packages/frontend/src/components/layout/nav-items.ts b/packages/frontend/src/components/layout/nav-items.ts index fadecf3..4f5df08 100644 --- a/packages/frontend/src/components/layout/nav-items.ts +++ b/packages/frontend/src/components/layout/nav-items.ts @@ -22,6 +22,7 @@ export const navItems: NavItem[] = [ { to: '/workouts', label: 'Workouts', icon: Dumbbell, end: false }, { to: '/reports', label: 'Reports', icon: FileText, end: false }, { to: '/actions', label: 'Actions', icon: CheckSquare, end: false }, + { to: '/plan', label: 'Plan', icon: Dumbbell, end: false }, { to: '/chat', label: 'Chat', icon: MessageCircle, end: false }, ]; diff --git a/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx b/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx new file mode 100644 index 0000000..21588ab --- /dev/null +++ b/packages/frontend/src/components/plan/AdjustmentReviewModal.tsx @@ -0,0 +1,302 @@ +import { useState, useCallback } from 'react'; +import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import type { PlanAdjustmentBatch, PlanAdjustment, ChangeType } from '@vitals/shared'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useDecideAdjustments } from '@/api/hooks/useWorkoutPlan'; + +interface AdjustmentReviewModalProps { + batch: PlanAdjustmentBatch; + open: boolean; + onClose: () => void; +} + +type Decision = 'accepted' | 'rejected'; + +const CHANGE_TYPE_LABELS: Record = { + hold: 'Hold', + progress_load: 'Progress load', + progress_reps: 'Progress reps', + deload: 'Deload', + swap: 'Swap', + remove: 'Remove', + add: 'Add', +}; + +const CHANGE_TYPE_COLORS: Record = { + hold: 'bg-muted text-muted-foreground', + progress_load: 'bg-green-500/15 text-green-700 dark:text-green-400', + progress_reps: 'bg-blue-500/15 text-blue-700 dark:text-blue-400', + deload: 'bg-orange-500/15 text-orange-700 dark:text-orange-400', + swap: 'bg-purple-500/15 text-purple-700 dark:text-purple-400', + remove: 'bg-destructive/15 text-destructive', + add: 'bg-green-500/15 text-green-700 dark:text-green-400', +}; + +function formatValue(value: unknown): string { + if (!value || typeof value !== 'object') return String(value ?? '—'); + const v = value as Record; + const sets = v['sets']; + if (Array.isArray(sets) && sets.length > 0) { + const s = sets[0] as Record; + const reps = Array.isArray(s['targetReps']) + ? `${s['targetReps'][0]}–${s['targetReps'][1]}` + : String(s['targetReps'] ?? '?'); + const load = s['targetWeightKg'] !== undefined ? `${s['targetWeightKg']} kg` : 'BW'; + return `${sets.length}×${reps} @ ${load}`; + } + return JSON.stringify(value); +} + +function ConfidenceDots({ score }: { score: number }) { + return ( + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + ); +} + +interface AdjustmentRowProps { + adjustment: PlanAdjustment; + decision: Decision; + onDecide: (id: string, d: Decision) => void; +} + +function AdjustmentRow({ adjustment, decision, onDecide }: AdjustmentRowProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+
+ + Exercise {adjustment.exerciseRef.exerciseOrder} + + + {CHANGE_TYPE_LABELS[adjustment.changeType]} + + +
+ +
+ + +
+
+ +

+ {formatValue(adjustment.oldValue)} + {' → '} + {formatValue(adjustment.newValue)} +

+ + + + {expanded && ( +
+

{adjustment.rationale}

+ {adjustment.evidence.length > 0 && ( +
    + {adjustment.evidence.map((ev, i) => ( +
  • + + {ev.kind} + + {ev.excerpt} +
  • + ))} +
+ )} +
+ )} +
+ ); +} + +/** + * Modal for reviewing AI-proposed plan adjustments. + * Grouped by day, per-row accept/reject, batch commit. + */ +export function AdjustmentReviewModal({ batch, open, onClose }: AdjustmentReviewModalProps) { + const [decisions, setDecisions] = useState>(() => { + const initial: Record = {}; + for (const adj of batch.adjustments) { + initial[adj.id] = 'accepted'; + } + return initial; + }); + const [commitError, setCommitError] = useState(null); + + const decide = useDecideAdjustments(batch.id); + + const handleDecide = useCallback((id: string, d: Decision) => { + setDecisions((prev) => ({ ...prev, [id]: d })); + }, []); + + const handleAcceptAll = () => { + const next: Record = {}; + for (const adj of batch.adjustments) next[adj.id] = 'accepted'; + setDecisions(next); + }; + + const handleRejectAll = () => { + const next: Record = {}; + for (const adj of batch.adjustments) next[adj.id] = 'rejected'; + setDecisions(next); + }; + + const acceptedCount = Object.values(decisions).filter((d) => d === 'accepted').length; + + const handleCommit = () => { + setCommitError(null); + decide.mutate( + { decisions }, + { + onSuccess: () => { + onClose(); + }, + onError: (err: unknown) => { + const msg = + (err as { message?: string })?.message ?? 'Failed to apply changes. Please try again.'; + setCommitError(msg); + }, + }, + ); + }; + + // Group adjustments by dayIndex + const byDay = batch.adjustments.reduce>((acc, adj) => { + const day = adj.exerciseRef.dayIndex; + if (!acc[day]) acc[day] = []; + acc[day].push(adj); + return acc; + }, {}); + + const dayIndexes = Object.keys(byDay) + .map(Number) + .sort((a, b) => a - b); + + const totalChanges = batch.adjustments.length; + const dayCount = dayIndexes.length; + + return ( + { + if (!isOpen && !decide.isPending) onClose(); + }} + > + + + Review plan adjustments for next week +

+ {totalChanges} change{totalChanges !== 1 ? 's' : ''} across {dayCount} day + {dayCount !== 1 ? 's' : ''} — review and accept. +

+
+ + {/* Triage row */} +
+ + +
+ + {/* Overall rationale */} + {batch.rationale && ( +

+ {batch.rationale} +

+ )} + + {/* Day-grouped diff list */} +
+ {dayIndexes.map((dayIndex) => ( +
+

+ Day {dayIndex + 1} +

+
+ {byDay[dayIndex].map((adj) => ( + + ))} +
+
+ ))} +
+ + {commitError && ( +

+ {commitError} +

+ )} + + + + + +
+
+ ); +} diff --git a/packages/frontend/src/components/plan/PlanDayCard.tsx b/packages/frontend/src/components/plan/PlanDayCard.tsx new file mode 100644 index 0000000..bf8cfe9 --- /dev/null +++ b/packages/frontend/src/components/plan/PlanDayCard.tsx @@ -0,0 +1,88 @@ +import type { PlanDay, PlanExercise, PlanSet } from '@vitals/shared'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +interface PlanDayCardProps { + day: PlanDay; + /** 0-based index of this day within the plan. */ + dayIndex: number; +} + +function formatReps(targetReps: PlanSet['targetReps']): string { + if (Array.isArray(targetReps)) { + return `${targetReps[0]}–${targetReps[1]}`; + } + return String(targetReps); +} + +function formatLoad(set: PlanSet): string { + if (set.targetWeightKg !== undefined) { + return `${set.targetWeightKg} kg`; + } + return 'bodyweight'; +} + +function getSetsDisplay(exercise: PlanExercise): string { + const workSets = exercise.sets.filter((s) => s.type === 'normal' || s.type === 'amrap'); + if (workSets.length === 0) return ''; + const first = workSets[0]; + return `${workSets.length}×${formatReps(first.targetReps)} @ ${formatLoad(first)}`; +} + +/** + * Displays a single training day: day name, target muscles, and a table of + * exercises with their sets × reps × load targets. + */ +export function PlanDayCard({ day, dayIndex: _dayIndex }: PlanDayCardProps) { + // Fallback: if no structured exercises but day.name is present with notes pattern + const hasExercises = day.exercises && day.exercises.length > 0; + + return ( + + +
+ {day.name} +
+ {day.targetMuscles.map((muscle) => ( + + {muscle} + + ))} +
+
+
+ + {hasExercises ? ( + + + + + + + + + + + {day.exercises.map((exercise, idx) => ( + + + + + + + ))} + +
ExerciseSets × Reps @ LoadRPENotes
{exercise.exerciseName}{getSetsDisplay(exercise)} + {exercise.sets.find((s) => s.targetRpe !== undefined)?.targetRpe ?? '—'} + + {exercise.notes ?? '—'} +
+ ) : ( +

+ {(day as { notes?: string }).notes ?? 'No exercises defined.'} +

+ )} +
+
+ ); +} diff --git a/packages/frontend/src/components/plan/PlanEditor.tsx b/packages/frontend/src/components/plan/PlanEditor.tsx new file mode 100644 index 0000000..e12bba6 --- /dev/null +++ b/packages/frontend/src/components/plan/PlanEditor.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useCreatePlan } from '@/api/hooks/useWorkoutPlan'; + +interface PlanEditorProps { + initialText?: string; + onSuccess?: () => void; +} + +const PLACEHOLDER = `Paste your workout plan here. Example: + +Push Day +Bench Press 3×8-12 @ 70kg +Overhead Press 3×8-10 @ 50kg +Tricep Pushdown 3×12-15 + +Pull Day +Barbell Row 3×8-10 @ 80kg +Pull-ups 3×6-10 +Bicep Curl 3×10-12`; + +/** + * PlanEditor — paste textarea + submit. + * Shows loading state while the parser/AI runs. + */ +export function PlanEditor({ initialText = '', onSuccess }: PlanEditorProps) { + const [rawText, setRawText] = useState(initialText); + const createPlan = useCreatePlan(); + + const handleSubmit = () => { + createPlan.mutate( + { rawText }, + { + onSuccess: () => { + onSuccess?.(); + }, + }, + ); + }; + + return ( +
+