diff --git a/server/prisma/dev.db b/server/prisma/dev.db index b22e9e3..aa07a47 100644 Binary files a/server/prisma/dev.db and b/server/prisma/dev.db differ diff --git a/specs/gymflow-test-plan.md b/specs/gymflow-test-plan.md index facd3e4..a7f700e 100644 --- a/specs/gymflow-test-plan.md +++ b/specs/gymflow-test-plan.md @@ -204,6 +204,22 @@ Comprehensive test plan for the GymFlow web application, covering authentication - The session starts with the selected plan's exercises. - The timer starts running. +#### 2.5a. A. Workout Plans - Create Plan from Session + +**File:** `tests/plan-from-session.spec.ts` + +**Steps:** + 1. User completes a session with multiple sets (e.g., 2 sets of Pushups, 1 set of Squats). + 2. Navigate to 'History'. + 3. Click 'Create Plan' from the session menu. + 4. Verify the Plan Editor opens. + 5. **Verify Steps**: The plan should contain exactly 3 steps (Pushups, Pushups, Squats). + +**Expected Results:** + - The Plan Editor is pre-filled. + - Plan steps mirror the session sets 1:1. + + #### 2.6. B. Exercise Library - Create Custom Exercise (Strength) **File:** `tests/workout-management.spec.ts` diff --git a/specs/requirements.md b/specs/requirements.md index 7c34d20..bfcf393 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -64,7 +64,17 @@ Users can structure their training via Plans. * **Logic**: Supports reordering capabilities via drag-and-drop in UI. * **3.2.2 Plan Deletion** * Standard soft or hard delete (Cascades to PlanExercises). -* **3.2.3 AI Plan Creation** +* **3.2.3 Create Plan from Session** + * **Trigger**: Action menu in History session. + * **Logic**: + * Creates a new Plan pre-filled with data from the selected session. + * **Step Generation**: Mirrors sets 1:1. Every recorded set in the session becomes a distinct step in the plan (no collapsing). + * **Attributes**: + * `startWeight`: inherited from set. + * `restTime`: uses User's default rest timer setting. + * `isWeighted`: true if the specific set had weight > 0. + +* **3.2.4 AI Plan Creation** * **Trigger**: "Create with AI" option in Plans FAB Menu, or "Ask your AI coach" link from Tracker (when no plans exist). * **UI Flow**: * Opens a dedicated Side Sheet in the Plans view. diff --git a/src/components/History.tsx b/src/components/History.tsx index 602efc9..0e9ebe2 100644 --- a/src/components/History.tsx +++ b/src/components/History.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; -import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save, MoreVertical, ClipboardList } from 'lucide-react'; import { TopBar } from './ui/TopBar'; import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types'; import { t } from '../services/i18n'; @@ -9,6 +11,7 @@ import { useSession } from '../context/SessionContext'; import { useAuth } from '../context/AuthContext'; import { getExercises } from '../services/storage'; import { Button } from './ui/Button'; +import { Ripple } from './ui/Ripple'; import { Card } from './ui/Card'; import { Modal } from './ui/Modal'; import { SideSheet } from './ui/SideSheet'; @@ -23,9 +26,11 @@ const History: React.FC = ({ lang }) => { const { sessions, updateSession, deleteSession } = useSession(); const { currentUser } = useAuth(); const userId = currentUser?.id || ''; + const navigate = useNavigate(); const [exercises, setExercises] = useState([]); const [editingSession, setEditingSession] = useState(null); + const [menuState, setMenuState] = useState<{ id: string, x: number, y: number } | null>(null); const [deletingId, setDeletingId] = useState(null); const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null); @@ -221,28 +226,23 @@ const History: React.FC = ({ lang }) => { -
+
-
@@ -328,6 +328,70 @@ const History: React.FC = ({ lang }) => { + {/* MENU PORTAL */} + {menuState && typeof document !== 'undefined' && createPortal( + <> +
{ + e.stopPropagation(); + setMenuState(null); + }} + /> +
+ + + +
+ , + document.body + )} + {/* DELETE CONFIRMATION MODAL */} = ({ lang }) => { setShowAISheet(true); setSearchParams({}); } - }, [searchParams, setSearchParams]); + + const sourceSessionId = searchParams.get('createFromSessionId'); + if (sourceSessionId && sessions.length > 0) { + const sourceSession = sessions.find(s => s.id === sourceSessionId); + if (sourceSession) { + handleCreateNew(); + + // Generate name + const dateStr = new Date(sourceSession.startTime).toLocaleDateString(); + setName(sourceSession.planName || (lang === 'ru' ? `План от ${dateStr}` : `Plan from ${dateStr}`)); + if (sourceSession.note) setDescription(sourceSession.note); + + // Generate steps from sets + const newSteps: PlannedSet[] = []; + let lastExerciseId: string | null = null; + + // Use default rest timer or 60s + const defaultRest = currentUser?.profile?.restTimerDefault || 60; + + sourceSession.sets.forEach(set => { + // Mirror every set from the session to the plan + newSteps.push({ + id: generateId(), + exerciseId: set.exerciseId, + exerciseName: set.exerciseName, + exerciseType: set.type, + isWeighted: (set.weight || 0) > 0, + restTimeSeconds: defaultRest + }); + }); + + setSteps(newSteps); + + // Clear param so we don't re-run + setSearchParams({}); + } + } + }, [searchParams, setSearchParams, sessions, currentUser]); const handleStart = (plan: WorkoutPlan) => { if (plan.description && plan.description.trim().length > 0) { diff --git a/src/components/ui/TopBar.tsx b/src/components/ui/TopBar.tsx index f0493ce..e95b526 100644 --- a/src/components/ui/TopBar.tsx +++ b/src/components/ui/TopBar.tsx @@ -9,7 +9,7 @@ interface TopBarProps { export const TopBar: React.FC = ({ title, icon: Icon, actions }) => { return ( -
+
{Icon && (
diff --git a/src/services/i18n.ts b/src/services/i18n.ts index e74ded4..e6465b3 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -111,6 +111,7 @@ const translations = { max: 'Max', upto: 'Up to', no_plan: 'No plan', + create_plan: 'Create Plan', // Plans plans_empty: 'No plans created', @@ -325,6 +326,7 @@ const translations = { max: 'Макс', upto: 'До', no_plan: 'Без плана', + create_plan: 'Создать план', // Plans plans_empty: 'Нет созданных планов', diff --git a/tailwind.config.js b/tailwind.config.js index 292cab5..8f1a005 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -63,15 +63,20 @@ export default { 'elevation-4': '0px 2px 3px rgba(0, 0, 0, 0.3), 0px 6px 10px 4px rgba(0, 0, 0, 0.15)', 'elevation-5': '0px 4px 4px rgba(0, 0, 0, 0.3), 0px 8px 12px 6px rgba(0, 0, 0, 0.15)', }, + animation: { + ripple: 'ripple 600ms linear', + 'menu-enter': 'menu-enter 200ms ease-out forwards', + }, keyframes: { ripple: { '0%': { transform: 'scale(0)', opacity: '0.4' }, '100%': { transform: 'scale(4)', opacity: '0' }, + }, + 'menu-enter': { + '0%': { opacity: '0', transform: 'scale(0.95) translateX(-100%)' }, // maintain the translate relative to position + '100%': { opacity: '1', transform: 'scale(1) translateX(-100%)' }, } }, - animation: { - ripple: 'ripple 600ms linear', - } }, }, plugins: [], diff --git a/tests/plan-from-session.spec.ts b/tests/plan-from-session.spec.ts new file mode 100644 index 0000000..2dde3eb --- /dev/null +++ b/tests/plan-from-session.spec.ts @@ -0,0 +1,158 @@ +import { test, expect } from './fixtures'; +import { generateId } from '../src/utils/uuid'; +import { ExerciseType } from '../src/types'; + +test('Create Plan from Session mirrors all sets 1:1', async ({ page, request, createUniqueUser }) => { + // 1. Setup User + const user = await createUniqueUser(); + + // 2. Create Exercises + const pushupsId = generateId(); + const squatsId = generateId(); + + // Directly seed exercises via API (assuming a helper or just DB seed if possible, + // but here we might need to use the app or just mock the session data if we can inject it? + // Actually, createUniqueUser returns a token. We can use it to POST /exercises if that endpoint exists, + // or just rely on 'default' exercises if they are seeded. + // Let's use the 'saveSession' endpoint directly logic if we can, or just mock the DB state. + // Wait, the app uses local storage mostly or sync? + // Based on other tests (which I can't read right now but recall structure), they usually use UI or API helpers. + // I will assume I can just Login and then use UI or API. + // Let's use UI to just ensure clean state, or API `POST /sessions` if available. + // Based on `server/src/routes/sessions.ts` existing, I can POST session. + + // Let's rely on standard UI flows or API. + // API is faster. + + const token = user.token; + + // Create Custom Exercises via API + await request.post('http://localhost:3000/api/exercises', { + headers: { Authorization: `Bearer ${token}` }, + data: { + id: pushupsId, + name: 'Test Pushups', + type: 'BODYWEIGHT', + isUnilateral: false + } + }); + + await request.post('http://localhost:3000/api/exercises', { + headers: { Authorization: `Bearer ${token}` }, + data: { + id: squatsId, + name: 'Test Squats', + type: 'STRENGTH', + isUnilateral: false + } + }); + + // 3. Create Session with 3 sets (A, A, B) + const sessionId = generateId(); + const sessionData = { + id: sessionId, + startTime: Date.now() - 3600000, // 1 hour ago + endTime: Date.now(), + note: 'Killer workout', + type: 'STANDARD', + sets: [ + { + id: generateId(), + exerciseId: pushupsId, + exerciseName: 'Test Pushups', + type: 'BODYWEIGHT', + reps: 10, + timestamp: Date.now() - 3000000, + completed: true + }, + { + id: generateId(), + exerciseId: pushupsId, + exerciseName: 'Test Pushups', + type: 'BODYWEIGHT', + reps: 12, + weight: 10, // Weighted + timestamp: Date.now() - 2000000, + completed: true + }, + { + id: generateId(), + exerciseId: squatsId, + exerciseName: 'Test Squats', + type: 'STRENGTH', + reps: 5, + weight: 100, + timestamp: Date.now() - 1000000, + completed: true + } + ] + }; + + await request.post('http://localhost:3000/api/sessions', { + headers: { Authorization: `Bearer ${token}` }, + data: sessionData + }); + + // 4. Login and Navigate + await page.goto('http://localhost:3000/'); + await page.fill('input[type="email"]', user.email); + await page.fill('input[type="password"]', user.password); + await page.click('button:has-text("Login")'); + await page.waitForURL('**/tracker'); + + // 5. Go to History + await page.click('text=History'); + + // 6. Click Create Plan + const sessionCard = page.locator('div.bg-surface-container').first(); // Assuming it's the first card + await sessionCard.waitFor(); + + // Open Menu + await sessionCard.locator('button[aria-label="Session Actions"]').click(); + // Click 'Create Plan' + await page.click('text=Create Plan'); + + // 7. Verify Redirection + await expect(page).toHaveURL(/.*plans\?createFromSessionId=.*/); + + // 8. Verify Plan Editor Content + await expect(page.locator('h2')).toContainText('Plan Editor'); + + // Name should be "Plan from [Date]" or Session Name + // Note: Session had no planName, so it defaults to date. + // But we can check the Description matches 'Killer workout' + await expect(page.locator('textarea')).toHaveValue('Killer workout'); + + // 9. Verify 3 Steps (1:1 mapping) + // We expect 3 cards in the sortable list + const steps = page.locator('.dnd-sortable-item_content, div[class*="items-center"] > div.flex-1'); + // Finding a robust selector for steps is tricky without specific test ids. + // The SortablePlanStep component has `div.text-base.font-medium.text-on-surface` for exercise name. + + const stepNames = page.locator('div.text-base.font-medium.text-on-surface'); + await expect(stepNames).toHaveCount(3); + + await expect(stepNames.nth(0)).toHaveText('Test Pushups'); + await expect(stepNames.nth(1)).toHaveText('Test Pushups'); + await expect(stepNames.nth(2)).toHaveText('Test Squats'); + + // 10. Verify Weighted Flag Logic + // Set 1 (index 0): Unweighted + // Set 2 (index 1): Weighted (weight: 10) + // Set 3 (index 2): Weighted (weight: 100) + + const checkboxes = page.locator('input[type="checkbox"]'); + // Warning: there might be other checkboxes. + // SortablePlanStep has a checkbox for 'weighted'. + // Better to look for checked state within the step card. + + // Step 1: Unchecked + await expect(page.locator('input[type="checkbox"]').nth(0)).not.toBeChecked(); + + // Step 2: Checked + await expect(page.locator('input[type="checkbox"]').nth(1)).toBeChecked(); + + // Step 3: Checked + await expect(page.locator('input[type="checkbox"]').nth(2)).toBeChecked(); + +});