ui/src/pages/NewRoutinePage.tsx (view raw)
1import { useState, useEffect } from 'react';
2import { useNavigate, useLocation, Link } from 'react-router-dom';
3import {
4 FaPlus,
5 FaSave,
6 FaArrowLeft,
7 FaTrash,
8 FaArrowUp,
9 FaArrowDown,
10 FaFilter,
11} from 'react-icons/fa';
12import type { Routine, Exercise, RoutineItem } from '../types/models';
13import { RoutineService, ExerciseService } from '../services/api';
14
15const NewRoutinePage = () => {
16 const navigate = useNavigate();
17 const location = useLocation();
18
19 // State for exercises
20 const [exercises, setExercises] = useState<Exercise[]>([]);
21 const [selectedItems, setSelectedItems] = useState<RoutineItem[]>([]);
22 const [searchTerm, setSearchTerm] = useState<string>('');
23 const [muscleFilter, setMuscleFilter] = useState<string>('');
24
25 // State for routine data
26 const [routineName, setRoutineName] = useState<string>('');
27 const [routineDescription, setRoutineDescription] = useState<string>('');
28 const [estimatedDuration, setEstimatedDuration] = useState<number>(0);
29
30 // UI state
31 const [isLoading, setIsLoading] = useState<boolean>(true);
32 const [isSaving, setIsSaving] = useState<boolean>(false);
33 const [error, setError] = useState<string | null>(null);
34 const [successMessage, setSuccessMessage] = useState<string | null>(null);
35
36 // Track if we're editing an existing routine
37 const [isEditMode, setIsEditMode] = useState<boolean>(false);
38 const [routineToEdit, setRoutineToEdit] = useState<Routine | null>(null);
39
40 // Fetch available exercises and check if we're in edit mode
41 useEffect(() => {
42 const fetchExercises = async () => {
43 try {
44 setIsLoading(true);
45 const data = await ExerciseService.getAll();
46 setExercises(data);
47
48 // Check if we're editing an existing routine
49 if (location.state && location.state.editRoutine) {
50 const routine = location.state.editRoutine as Routine;
51 setRoutineName(routine.name);
52 setRoutineDescription(routine.description);
53
54 // Set selected items from the routine
55 if (routine.routineItems && routine.routineItems.length > 0) {
56 setSelectedItems(routine.routineItems);
57 }
58
59 setRoutineToEdit(routine);
60 setIsEditMode(true);
61 }
62
63 setError(null);
64 } catch (err) {
65 console.error('Failed to fetch exercises:', err);
66 setError('Could not load exercises. Please try again later.');
67 } finally {
68 setIsLoading(false);
69 }
70 };
71
72 fetchExercises();
73 }, [location]);
74
75 // Find unique muscle groups for filtering
76 const muscleGroups = [...new Set(exercises.flatMap(ex =>
77 ex.muscleGroups.map(mg => mg.name)
78 ))].sort();
79
80 // Filter exercises based on search and muscle filter
81 const filteredExercises = exercises.filter(ex => {
82 const matchesSearch = ex.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
83 ex.description.toLowerCase().includes(searchTerm.toLowerCase());
84 const matchesMuscle = !muscleFilter ||
85 ex.muscleGroups.some(mg => mg.name === muscleFilter);
86 return matchesSearch && matchesMuscle;
87 });
88
89 // Handle adding an exercise to the routine
90 const handleAddExercise = (exercise: Exercise) => {
91 const newItem: RoutineItem = {
92 routineId: routineToEdit?.id || 0,
93 exerciseId: exercise.id,
94 superSetId: null,
95 restTime: 60, // Default rest time 60 seconds
96 orderIndex: selectedItems.length,
97 exercise: exercise,
98 superSet: null,
99 };
100
101 setSelectedItems([...selectedItems, newItem]);
102 };
103
104 // Handle removing an exercise from the routine
105 const handleRemoveItem = (index: number) => {
106 const newSelectedItems = [...selectedItems];
107 newSelectedItems.splice(index, 1);
108
109 // Update order values
110 const reorderedItems = newSelectedItems.map((item, i) => ({
111 ...item,
112 orderIndex: i,
113 }));
114
115 setSelectedItems(reorderedItems);
116 };
117
118 // Handle updating exercise details
119 const handleItemChange = (index: number, field: string, value: any) => {
120 const newSelectedItems = [...selectedItems];
121 // @ts-ignore
122 newSelectedItems[index][field] = value;
123 setSelectedItems(newSelectedItems);
124 };
125
126 // Handle moving exercises up/down in the order
127 const handleMoveItem = (index: number, direction: 'up' | 'down') => {
128 if (
129 (direction === 'up' && index === 0) ||
130 (direction === 'down' && index === selectedItems.length - 1)
131 ) {
132 return;
133 }
134
135 const newSelectedItems = [...selectedItems];
136 const swapIndex = direction === 'up' ? index - 1 : index + 1;
137
138 // Swap items
139 [newSelectedItems[index], newSelectedItems[swapIndex]] =
140 [newSelectedItems[swapIndex], newSelectedItems[index]];
141
142 // Update order values
143 const reorderedItems = newSelectedItems.map((item, i) => ({
144 ...item,
145 orderIndex: i,
146 }));
147
148 setSelectedItems(reorderedItems);
149 };
150
151 // Calculate estimated duration based on exercises and rest times
152 useEffect(() => {
153 let totalMinutes = 0;
154
155 selectedItems.forEach(item => {
156 if (item.exercise) {
157 // Estimate time for each set (1 min per set) + rest time
158 const setCount = item.exercise.sets?.length || 3; // Default to 3 if no sets defined
159 const restTime = item.restTime / 60; // Convert seconds to minutes
160 totalMinutes += setCount + restTime;
161 } else if (item.superSet) {
162 // For supersets, account for both exercises
163 const primarySets = item.superSet.primaryExercise?.sets?.length || 3;
164 const secondarySets = item.superSet.secondaryExercise?.sets?.length || 3;
165 const restTime = item.superSet.restTime / 60;
166 totalMinutes += primarySets + secondarySets + restTime;
167 }
168 });
169
170 // Add some buffer time for transitions between exercises
171 totalMinutes += selectedItems.length > 0 ? Math.ceil(selectedItems.length / 3) : 0;
172
173 setEstimatedDuration(Math.ceil(totalMinutes));
174 }, [selectedItems]);
175
176 // Handle saving the routine
177 const handleSaveRoutine = async (e: React.FormEvent) => {
178 e.preventDefault();
179
180 if (selectedItems.length === 0) {
181 setError('Please add at least one exercise to your routine.');
182 return;
183 }
184
185 setIsSaving(true);
186 setError(null);
187
188 // Prepare the routineItems by removing circular references
189 const sanitizedItems = selectedItems.map(item => {
190 const { exercise, superSet, ...rest } = item;
191 return rest;
192 });
193
194 const routineData: Routine = {
195 name: routineName,
196 description: routineDescription,
197 routineItems: sanitizedItems,
198 };
199
200 try {
201 if (isEditMode && routineToEdit?.id) {
202 // Update existing routine
203 await RoutineService.update(routineToEdit.id, routineData);
204 setSuccessMessage('Routine updated successfully!');
205 } else {
206 // Create new routine
207 await RoutineService.create(routineData);
208 setSuccessMessage('Routine created successfully!');
209 }
210
211 // Show success message briefly then redirect
212 setTimeout(() => {
213 navigate('/workouts');
214 }, 1500);
215 } catch (err) {
216 console.error('Failed to save routine:', err);
217 setError('Failed to save routine. Please try again.');
218 } finally {
219 setIsSaving(false);
220 }
221 };
222
223 return (
224 <div className="page new-routine-page">
225 <div className="page-header">
226 <button
227 onClick={() => navigate(-1)}
228 className="btn btn-secondary back-button"
229 >
230 <FaArrowLeft /> Back
231 </button>
232 <h1>{isEditMode ? 'Edit Routine' : 'Create Routine'}</h1>
233 </div>
234
235 {error && <div className="error-message">{error}</div>}
236 {successMessage && <div className="success-message">{successMessage}</div>}
237
238 {isLoading ? (
239 <div className="loading">Loading exercises...</div>
240 ) : (
241 <div className="routine-builder">
242 <div className="routine-details card">
243 <h2>Routine Details</h2>
244 <form onSubmit={handleSaveRoutine}>
245 <div className="form-group">
246 <label htmlFor="routineName">Routine Name*</label>
247 <input
248 type="text"
249 id="routineName"
250 value={routineName}
251 onChange={(e) => setRoutineName(e.target.value)}
252 placeholder="e.g. Upper Body Strength"
253 required
254 disabled={isSaving}
255 />
256 </div>
257
258 <div className="form-group">
259 <label htmlFor="routineDescription">Description</label>
260 <textarea
261 id="routineDescription"
262 value={routineDescription}
263 onChange={(e) => setRoutineDescription(e.target.value)}
264 placeholder="Describe your routine..."
265 rows={3}
266 disabled={isSaving}
267 />
268 </div>
269
270 <div className="routine-summary">
271 <div className="summary-item">
272 <span className="summary-label">Exercises:</span>
273 <span className="summary-value">{selectedItems.length}</span>
274 </div>
275 <div className="summary-item">
276 <span className="summary-label">Est. Duration:</span>
277 <span className="summary-value">{estimatedDuration} min</span>
278 </div>
279 </div>
280
281 <div className="selected-exercises">
282 <h3>Selected Exercises</h3>
283
284 {selectedItems.length === 0 ? (
285 <div className="empty-state">
286 <p>No exercises added yet.</p>
287 <p className="hint">Select exercises from the list below to add them to your routine.</p>
288 </div>
289 ) : (
290 <div className="exercise-list">
291 {selectedItems.map((item, index) => (
292 <div key={`${index}-${item.exerciseId || item.superSetId}`} className="selected-exercise-item">
293 <div className="exercise-order">#{index + 1}</div>
294
295 <div className="exercise-content">
296 <div className="exercise-name">{item.exercise?.name || (item.superSet ? `${item.superSet.primaryExercise.name} + ${item.superSet.secondaryExercise.name}` : "Unknown Exercise")}</div>
297
298 <div className="exercise-details">
299 <div className="detail-item">
300 <label htmlFor={`rest-${index}`}>Rest (sec):</label>
301 <input
302 id={`rest-${index}`}
303 type="number"
304 min="0"
305 max="300"
306 step="15"
307 value={item.restTime}
308 onChange={(e) => handleItemChange(index, 'restTime', parseInt(e.target.value))}
309 disabled={isSaving}
310 />
311 </div>
312 </div>
313 </div>
314
315 <div className="exercise-actions">
316 <button
317 type="button"
318 onClick={() => handleMoveItem(index, 'up')}
319 disabled={index === 0 || isSaving}
320 className="btn btn-secondary action-btn"
321 title="Move up"
322 >
323 <FaArrowUp />
324 </button>
325 <button
326 type="button"
327 onClick={() => handleMoveItem(index, 'down')}
328 disabled={index === selectedItems.length - 1 || isSaving}
329 className="btn btn-secondary action-btn"
330 title="Move down"
331 >
332 <FaArrowDown />
333 </button>
334 <button
335 type="button"
336 onClick={() => handleRemoveItem(index)}
337 disabled={isSaving}
338 className="btn btn-danger action-btn"
339 title="Remove"
340 >
341 <FaTrash />
342 </button>
343 </div>
344 </div>
345 ))}
346 </div>
347 )}
348 </div>
349
350 <div className="form-actions">
351 <button
352 type="submit"
353 className="btn btn-primary btn-block"
354 disabled={isSaving || selectedItems.length === 0 || !routineName}
355 >
356 <FaSave /> {isEditMode ? 'Update Routine' : 'Save Routine'}
357 </button>
358 </div>
359 </form>
360 </div>
361
362 <div className="exercise-picker card">
363 <h2>Available Exercises</h2>
364
365 <div className="exercise-filters">
366 <div className="search-input-container">
367 <FaFilter />
368 <input
369 type="text"
370 placeholder="Search exercises..."
371 value={searchTerm}
372 onChange={(e) => setSearchTerm(e.target.value)}
373 className="search-input"
374 />
375 </div>
376
377 <div className="muscle-filter">
378 <select
379 value={muscleFilter}
380 onChange={(e) => setMuscleFilter(e.target.value)}
381 >
382 <option value="">All Muscle Groups</option>
383 {muscleGroups.map(group => (
384 <option key={group} value={group}>{group}</option>
385 ))}
386 </select>
387 </div>
388 </div>
389
390 {filteredExercises.length === 0 && (
391 <div className="empty-state">
392 <p>No exercises found.</p>
393 <Link to="/new-exercise" className="btn btn-primary mt-md">
394 <FaPlus /> Create New Exercise
395 </Link>
396 </div>
397 )}
398
399 <div className="available-exercises">
400 {filteredExercises.map(exercise => (
401 <div key={exercise.id} className="exercise-item">
402 <div className="exercise-info">
403 <h4>{exercise.name}</h4>
404 <div className="exercise-metadata">
405 <span>{exercise.muscleGroups.map(mg => mg.name).join(', ')}</span>
406 <span>{exercise.equipment.map(eq => eq.name).join(', ')}</span>
407 </div>
408 {exercise.description && (
409 <div className="exercise-description">{exercise.description}</div>
410 )}
411 </div>
412 <button
413 type="button"
414 onClick={() => handleAddExercise(exercise)}
415 className="btn btn-primary"
416 disabled={isSaving}
417 >
418 <FaPlus /> Add
419 </button>
420 </div>
421 ))}
422 </div>
423
424 <div className="text-center mt-lg">
425 <Link to="/new-exercise" className="btn btn-secondary">
426 <FaPlus /> Create New Exercise
427 </Link>
428 </div>
429 </div>
430 </div>
431 )}
432
433 <style>{`
434 .page-header {
435 display: flex;
436 align-items: center;
437 margin-bottom: var(--spacing-lg);
438 }
439
440 .back-button {
441 margin-right: var(--spacing-md);
442 padding: var(--spacing-sm) var(--spacing-md);
443 }
444
445 .routine-builder {
446 display: grid;
447 gap: var(--spacing-lg);
448 }
449
450 @media (min-width: 768px) {
451 .routine-builder {
452 grid-template-columns: 1fr 1fr;
453 }
454 }
455
456 .routine-summary {
457 display: flex;
458 gap: var(--spacing-lg);
459 margin: var(--spacing-md) 0;
460 }
461
462 .summary-item {
463 padding: var(--spacing-sm) var(--spacing-md);
464 background-color: rgba(0, 122, 255, 0.1);
465 border-radius: var(--border-radius);
466 display: flex;
467 align-items: center;
468 gap: var(--spacing-sm);
469 }
470
471 .summary-label {
472 font-weight: 500;
473 }
474
475 .selected-exercises {
476 margin-top: var(--spacing-lg);
477 }
478
479 h3 {
480 margin-bottom: var(--spacing-md);
481 }
482
483 .selected-exercise-item {
484 display: flex;
485 align-items: center;
486 padding: var(--spacing-md);
487 border: 1px solid var(--border-color);
488 border-radius: var(--border-radius);
489 margin-bottom: var(--spacing-sm);
490 background-color: white;
491 }
492
493 .exercise-order {
494 font-weight: bold;
495 width: 30px;
496 height: 30px;
497 border-radius: 50%;
498 background-color: var(--primary-color);
499 color: white;
500 display: flex;
501 align-items: center;
502 justify-content: center;
503 margin-right: var(--spacing-md);
504 }
505
506 .exercise-content {
507 flex: 1;
508 }
509
510 .exercise-name {
511 font-weight: 600;
512 margin-bottom: var(--spacing-xs);
513 }
514
515 .exercise-details {
516 display: flex;
517 flex-wrap: wrap;
518 gap: var(--spacing-md);
519 }
520
521 .detail-item {
522 display: flex;
523 align-items: center;
524 gap: var(--spacing-xs);
525 }
526
527 .detail-item input {
528 width: 60px;
529 padding: 4px;
530 text-align: center;
531 }
532
533 .exercise-actions {
534 display: flex;
535 gap: var(--spacing-xs);
536 }
537
538 .action-btn {
539 padding: 5px;
540 font-size: 0.8rem;
541 }
542
543 .exercise-filters {
544 display: flex;
545 gap: var(--spacing-md);
546 margin-bottom: var(--spacing-md);
547 }
548
549 .search-input-container {
550 position: relative;
551 flex: 1;
552 }
553
554 .search-input-container .fa-filter {
555 position: absolute;
556 left: 10px;
557 top: 50%;
558 transform: translateY(-50%);
559 color: var(--text-muted);
560 }
561
562 .search-input {
563 padding-left: 30px;
564 width: 100%;
565 }
566
567 .available-exercises {
568 max-height: 500px;
569 overflow-y: auto;
570 }
571
572 .exercise-item {
573 display: flex;
574 justify-content: space-between;
575 align-items: center;
576 padding: var(--spacing-md);
577 border-bottom: 1px solid var(--border-color);
578 }
579
580 .exercise-info {
581 flex: 1;
582 }
583
584 .exercise-info h4 {
585 margin: 0;
586 margin-bottom: var(--spacing-xs);
587 }
588
589 .exercise-metadata {
590 font-size: 0.85rem;
591 color: var(--text-muted);
592 display: flex;
593 gap: var(--spacing-md);
594 margin-bottom: var(--spacing-xs);
595 }
596
597 .exercise-description {
598 font-size: 0.9rem;
599 display: -webkit-box;
600 -webkit-line-clamp: 2;
601 -webkit-box-orient: vertical;
602 overflow: hidden;
603 text-overflow: ellipsis;
604 }
605
606 .empty-state {
607 padding: var(--spacing-lg);
608 text-align: center;
609 color: var(--text-muted);
610 }
611
612 .hint {
613 font-size: 0.9rem;
614 margin-top: var(--spacing-md);
615 }
616
617 .mt-md {
618 margin-top: var(--spacing-md);
619 }
620
621 .mt-lg {
622 margin-top: var(--spacing-lg);
623 }
624
625 .text-center {
626 text-align: center;
627 }
628
629 .loading {
630 text-align: center;
631 padding: var(--spacing-lg);
632 color: var(--text-muted);
633 }
634
635 .error-message {
636 background-color: rgba(255, 59, 48, 0.1);
637 color: var(--danger-color);
638 padding: var(--spacing-md);
639 border-radius: var(--border-radius);
640 margin-bottom: var(--spacing-lg);
641 }
642
643 .success-message {
644 background-color: rgba(52, 199, 89, 0.1);
645 color: var(--success-color);
646 padding: var(--spacing-md);
647 border-radius: var(--border-radius);
648 margin-bottom: var(--spacing-lg);
649 }
650 `}</style>
651 </div>
652 );
653};
654
655export default NewRoutinePage;