Browse Source

feat(workout-builder): implement shuffle exercise action to enhance workout customization and user experience
fix(workout-builder): add loading state for shuffling exercises to improve UI responsiveness and feedback

Mathias 1 month ago
parent
commit
acbd1483f2

+ 162 - 0
src/features/workout-builder/actions/shuffle-exercise.action.ts

@@ -0,0 +1,162 @@
+"use server";
+
+import { z } from "zod";
+import { ExerciseAttributeNameEnum, ExerciseAttributeValueEnum } from "@prisma/client";
+
+import { prisma } from "@/shared/lib/prisma";
+import { actionClient } from "@/shared/api/safe-actions";
+
+const shuffleExerciseSchema = z.object({
+  currentExerciseId: z.string(),
+  muscle: z.nativeEnum(ExerciseAttributeValueEnum),
+  equipment: z.array(z.nativeEnum(ExerciseAttributeValueEnum)),
+  excludeExerciseIds: z.array(z.string()),
+});
+
+export const shuffleExerciseAction = actionClient.schema(shuffleExerciseSchema).action(async ({ parsedInput }) => {
+  const { currentExerciseId, muscle, equipment, excludeExerciseIds } = parsedInput;
+
+  try {
+    const [primaryMuscleAttributeName, secondaryMuscleAttributeName, equipmentAttributeName] = await Promise.all([
+      prisma.exerciseAttributeName.findUnique({
+        where: { name: ExerciseAttributeNameEnum.PRIMARY_MUSCLE },
+      }),
+      prisma.exerciseAttributeName.findUnique({
+        where: { name: ExerciseAttributeNameEnum.SECONDARY_MUSCLE },
+      }),
+      prisma.exerciseAttributeName.findUnique({
+        where: { name: ExerciseAttributeNameEnum.EQUIPMENT },
+      }),
+    ]);
+
+    if (!primaryMuscleAttributeName || !secondaryMuscleAttributeName || !equipmentAttributeName) {
+      throw new Error("Missing attributes in database");
+    }
+
+    const primaryExercises = await prisma.exercise.findMany({
+      where: {
+        AND: [
+          {
+            id: {
+              notIn: excludeExerciseIds,
+            },
+          },
+          {
+            attributes: {
+              some: {
+                attributeNameId: primaryMuscleAttributeName.id,
+                attributeValue: {
+                  value: muscle,
+                },
+              },
+            },
+          },
+          {
+            attributes: {
+              some: {
+                attributeNameId: equipmentAttributeName.id,
+                attributeValue: {
+                  value: {
+                    in: equipment,
+                  },
+                },
+              },
+            },
+          },
+          {
+            NOT: {
+              attributes: {
+                some: {
+                  attributeValue: {
+                    value: ExerciseAttributeValueEnum.STRETCHING,
+                  },
+                },
+              },
+            },
+          },
+        ],
+      },
+      include: {
+        attributes: {
+          include: {
+            attributeName: true,
+            attributeValue: true,
+          },
+        },
+      },
+      take: 50,
+    });
+
+    let allExercises = [...primaryExercises];
+
+    if (allExercises.length < 3) {
+      const secondaryExercises = await prisma.exercise.findMany({
+        where: {
+          AND: [
+            {
+              id: {
+                notIn: [...excludeExerciseIds, ...primaryExercises.map((ex) => ex.id)],
+              },
+            },
+            {
+              attributes: {
+                some: {
+                  attributeNameId: secondaryMuscleAttributeName.id,
+                  attributeValue: {
+                    value: muscle,
+                  },
+                },
+              },
+            },
+            {
+              attributes: {
+                some: {
+                  attributeNameId: equipmentAttributeName.id,
+                  attributeValue: {
+                    value: {
+                      in: equipment,
+                    },
+                  },
+                },
+              },
+            },
+            {
+              NOT: {
+                attributes: {
+                  some: {
+                    attributeValue: {
+                      value: ExerciseAttributeValueEnum.STRETCHING,
+                    },
+                  },
+                },
+              },
+            },
+          ],
+        },
+        include: {
+          attributes: {
+            include: {
+              attributeName: true,
+              attributeValue: true,
+            },
+          },
+        },
+        take: 50 - primaryExercises.length,
+      });
+
+      allExercises = [...allExercises, ...secondaryExercises];
+    }
+
+    if (allExercises.length === 0) {
+      return { serverError: "No alternative exercises found" };
+    }
+
+    const randomIndex = Math.floor(Math.random() * allExercises.length);
+    const selectedExercise = allExercises[randomIndex];
+
+    return { exercise: selectedExercise };
+  } catch (error) {
+    console.error("Error shuffling exercise:", error);
+    return { serverError: "Failed to shuffle exercise" };
+  }
+});

+ 8 - 0
src/features/workout-builder/model/use-workout-stepper.ts

@@ -20,6 +20,8 @@ export function useWorkoutStepper() {
     fetchExercises,
     exercisesOrder,
     setExercisesOrder,
+    shuffleExercise,
+    isShuffling,
   } = useWorkoutBuilderStore();
 
   // Validation des étapes
@@ -60,5 +62,11 @@ export function useWorkoutStepper() {
     // Order
     exercisesOrder,
     setExercisesOrder,
+
+    // Shuffle
+    shuffleExercise,
+
+    // Additional
+    isShuffling,
   };
 }

+ 50 - 0
src/features/workout-builder/model/workout-builder.store.ts

@@ -2,6 +2,7 @@ import { create } from "zustand";
 import { ExerciseAttributeValueEnum, WorkoutSessionExercise } from "@prisma/client";
 
 import { WorkoutBuilderStep } from "../types";
+import { shuffleExerciseAction } from "../actions/shuffle-exercise.action";
 import { getExercisesAction } from "./get-exercises.action";
 
 interface WorkoutBuilderState {
@@ -13,6 +14,7 @@ interface WorkoutBuilderState {
   isLoadingExercises: boolean;
   exercisesError: any;
   exercisesOrder: string[];
+  isShuffling: boolean;
 
   // Actions
   setStep: (step: WorkoutBuilderStep) => void;
@@ -24,6 +26,7 @@ interface WorkoutBuilderState {
   clearMuscles: () => void;
   fetchExercises: () => Promise<void>;
   setExercisesOrder: (order: string[]) => void;
+  shuffleExercise: (exerciseId: string, muscle: ExerciseAttributeValueEnum) => Promise<void>;
   loadFromSession: (params: {
     equipment: ExerciseAttributeValueEnum[];
     muscles: ExerciseAttributeValueEnum[];
@@ -43,6 +46,7 @@ export const useWorkoutBuilderStore = create<WorkoutBuilderState>((set, get) =>
   isLoadingExercises: false,
   exercisesError: null,
   exercisesOrder: [],
+  isShuffling: false,
 
   setStep: (step) => set({ currentStep: step }),
   nextStep: () => set((state) => ({ currentStep: Math.min(state.currentStep + 1, 3) as WorkoutBuilderStep })),
@@ -84,6 +88,52 @@ export const useWorkoutBuilderStore = create<WorkoutBuilderState>((set, get) =>
 
   setExercisesOrder: (order) => set({ exercisesOrder: order }),
 
+  shuffleExercise: async (exerciseId, muscle) => {
+    set({ isShuffling: true });
+    try {
+      const { selectedEquipment, exercisesByMuscle } = get();
+
+      // Récupérer tous les IDs des exercices dans le workout actuel
+      const allExerciseIds = exercisesByMuscle.flatMap((group) => group.exercises.map((ex: any) => ex.id));
+
+      const result = await shuffleExerciseAction({
+        currentExerciseId: exerciseId,
+        muscle: muscle,
+        equipment: selectedEquipment,
+        excludeExerciseIds: allExerciseIds,
+      });
+
+      if (result?.serverError) {
+        throw new Error(result.serverError);
+      }
+
+      if (result?.data?.exercise) {
+        const newExercise = result.data.exercise;
+
+        set((state) => ({
+          exercisesByMuscle: state.exercisesByMuscle.map((group) => {
+            if (group.muscle === muscle) {
+              return {
+                ...group,
+                exercises: group.exercises.map((ex: any) => (ex.id === exerciseId ? { ...newExercise, order: ex.order } : ex)),
+              };
+            }
+            return group;
+          }),
+        }));
+
+        set((state) => ({
+          exercisesOrder: state.exercisesOrder.map((id) => (id === exerciseId ? newExercise.id : id)),
+        }));
+      }
+    } catch (error) {
+      console.error("Error shuffling exercise:", error);
+      throw error;
+    } finally {
+      set({ isShuffling: false });
+    }
+  },
+
   loadFromSession: ({ equipment, muscles, exercisesByMuscle, exercisesOrder }) => {
     set({
       selectedEquipment: equipment,

+ 11 - 4
src/features/workout-builder/ui/exercise-list-item.tsx

@@ -1,6 +1,6 @@
 import { useState } from "react";
 import Image from "next/image";
-import { Play, Shuffle, Star, Trash2, GripVertical } from "lucide-react";
+import { Play, Shuffle, Star, Trash2, GripVertical, Loader2 } from "lucide-react";
 import { CSS } from "@dnd-kit/utilities";
 import { useSortable } from "@dnd-kit/sortable";
 
@@ -18,9 +18,10 @@ interface ExerciseListItemProps {
   onShuffle: (exerciseId: string, muscle: string) => void;
   onPick: (exerciseId: string) => void;
   onDelete: (exerciseId: string, muscle: string) => void;
+  isShuffling?: boolean;
 }
 
-export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete }: ExerciseListItemProps) {
+export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete, isShuffling }: ExerciseListItemProps) {
   const t = useI18n();
   const [isHovered, setIsHovered] = useState(false);
   const locale = useCurrentLocale();
@@ -121,8 +122,14 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
 
         <div className="flex items-center gap-1 sm:gap-2 shrink-0">
           {/* Bouton shuffle */}
-          <Button className="p-1 sm:p-2" onClick={() => onShuffle(exercise.id, muscle)} size="small" variant="outline">
-            <Shuffle className="h-3.5 w-3.5" />
+          <Button
+            className="p-1 sm:p-2"
+            disabled={isShuffling}
+            onClick={() => onShuffle(exercise.id, muscle)}
+            size="small"
+            variant="outline"
+          >
+            {isShuffling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Shuffle className="h-3.5 w-3.5" />}
             <span className="hidden sm:inline">{t("workout_builder.exercise.shuffle")}</span>
           </Button>
 

+ 5 - 2
src/features/workout-builder/ui/exercises-selection.tsx

@@ -19,6 +19,7 @@ interface ExercisesSelectionProps {
   onPick: (exerciseId: string) => void;
   onDelete: (exerciseId: string, muscle: string) => void;
   onAdd: () => void;
+  isShuffling?: boolean;
 }
 
 export const ExercisesSelection = ({
@@ -29,6 +30,7 @@ export const ExercisesSelection = ({
   onPick,
   onDelete,
   onAdd,
+  isShuffling,
 }: ExercisesSelectionProps) => {
   const t = useI18n();
   const [flatExercises, setFlatExercises] = useState<{ id: string; muscle: string; exercise: ExerciseWithAttributes }[]>([]);
@@ -89,10 +91,11 @@ export const ExercisesSelection = ({
           <DndContext collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} onDragEnd={handleDragEnd} sensors={sensors}>
             <SortableContext items={flatExercises.map((item) => item.id)} strategy={verticalListSortingStrategy}>
               <div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
-                {flatExercises.map((item) => (
+                {flatExercises.map((item, index) => (
                   <ExerciseListItem
                     exercise={item.exercise}
-                    key={`${item.id}-${item.exercise.order}`}
+                    isShuffling={isShuffling}
+                    key={`${item.id}-${index}`}
                     muscle={item.muscle}
                     onDelete={onDelete}
                     onPick={onPick}

+ 12 - 3
src/features/workout-builder/ui/workout-stepper.tsx

@@ -45,6 +45,8 @@ export function WorkoutStepper() {
     exercisesError,
     fetchExercises,
     exercisesOrder,
+    shuffleExercise,
+    isShuffling,
   } = useWorkoutStepper();
 
   useEffect(() => {
@@ -86,9 +88,15 @@ export function WorkoutStepper() {
 
   const canContinue = currentStep === 1 ? canProceedToStep2 : currentStep === 2 ? canProceedToStep3 : exercisesByMuscle.length > 0;
 
-  const handleShuffleExercise = (exerciseId: string, muscle: string) => {
-    alert("TODO : Shuffle exercise");
-    console.log("Shuffle exercise:", exerciseId, "for muscle:", muscle);
+  const handleShuffleExercise = async (exerciseId: string, muscle: string) => {
+    try {
+      // Convertir le muscle string vers enum
+      const muscleEnum = muscle as ExerciseAttributeValueEnum;
+      await shuffleExercise(exerciseId, muscleEnum);
+    } catch (error) {
+      console.error("Error shuffling exercise:", error);
+      alert("Error shuffling exercise. Please try again.");
+    }
   };
 
   const handlePickExercise = (exerciseId: string) => {
@@ -220,6 +228,7 @@ export function WorkoutStepper() {
             error={exercisesError}
             exercisesByMuscle={exercisesByMuscle}
             isLoading={isLoadingExercises}
+            isShuffling={isShuffling}
             onAdd={handleAddExercise}
             onDelete={handleDeleteExercise}
             onPick={handlePickExercise}