all repos — go-lift @ 387721fc5e90ba268efbae885c9baf8e0a543f28

Lightweight workout tracker prototype..

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;