Преглед изворни кода

Merge pull request #23 from Snouzy/feat/shuffle

feat/shuffle
Mat B. пре 1 месец
родитељ
комит
af98d5c333

+ 1 - 0
locales/en.ts

@@ -93,6 +93,7 @@ export default {
 
   // Workout Builder
   workout_builder: {
+    confirm_delete: "Are you sure you want to delete this workout session?",
     steps: {
       equipment: {
         title: "Equipment",

+ 1 - 0
locales/fr.ts

@@ -93,6 +93,7 @@ export default {
 
   // Workout Builder
   workout_builder: {
+    confirm_delete: "Êtes-vous sûr de vouloir supprimer cette séance ?",
     steps: {
       equipment: {
         title: "Équipement",

+ 11 - 0
prisma/migrations/20250615170916_add_cascade_delete_workout_sessions/migration.sql

@@ -0,0 +1,11 @@
+-- DropForeignKey
+ALTER TABLE "workout_session_exercises" DROP CONSTRAINT "workout_session_exercises_workoutSessionId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "workout_sets" DROP CONSTRAINT "workout_sets_workoutSessionExerciseId_fkey";
+
+-- AddForeignKey
+ALTER TABLE "workout_session_exercises" ADD CONSTRAINT "workout_session_exercises_workoutSessionId_fkey" FOREIGN KEY ("workoutSessionId") REFERENCES "workout_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "workout_sets" ADD CONSTRAINT "workout_sets_workoutSessionExerciseId_fkey" FOREIGN KEY ("workoutSessionExerciseId") REFERENCES "workout_session_exercises"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 2 - 2
prisma/schema.prisma

@@ -284,7 +284,7 @@ model WorkoutSessionExercise {
   workoutSessionId String
   exerciseId       String
   order            Int
-  workoutSession   WorkoutSession @relation(fields: [workoutSessionId], references: [id])
+  workoutSession   WorkoutSession @relation(fields: [workoutSessionId], references: [id], onDelete: Cascade)
   exercise         Exercise       @relation(fields: [exerciseId], references: [id])
   sets             WorkoutSet[]
 
@@ -301,7 +301,7 @@ model WorkoutSet {
   valuesSec                Int[]                  @default([])
   units                    WorkoutSetUnit[]       @default([])
   completed                Boolean                @default(false)
-  workoutSessionExercise   WorkoutSessionExercise @relation(fields: [workoutSessionExerciseId], references: [id])
+  workoutSessionExercise   WorkoutSessionExercise @relation(fields: [workoutSessionExerciseId], references: [id], onDelete: Cascade)
 
   @@map("workout_sets")
 }

+ 0 - 1
src/entities/user/model/get-server-session-user.ts

@@ -10,7 +10,6 @@ export class AuthError extends Error {
 
 export const serverAuth = async () => {
   const session = await auth.api.getSession({ headers: await headers() });
-  console.log("session:", session);
 
   if (session && session.user) {
     return session.user;

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

@@ -0,0 +1,161 @@
+"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({
+  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 { 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,
   };
 }

+ 48 - 3
src/features/workout-builder/model/workout-builder.store.ts

@@ -2,17 +2,19 @@ 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 {
   currentStep: WorkoutBuilderStep;
   selectedEquipment: ExerciseAttributeValueEnum[];
   selectedMuscles: ExerciseAttributeValueEnum[];
-  // Exercices (groupés par muscle)
-  exercisesByMuscle: any[];
+
+  exercisesByMuscle: any[]; //TODO: type this
   isLoadingExercises: boolean;
-  exercisesError: any;
+  exercisesError: any; //TODO: type this
   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,47 @@ export const useWorkoutBuilderStore = create<WorkoutBuilderState>((set, get) =>
 
   setExercisesOrder: (order) => set({ exercisesOrder: order }),
 
+  shuffleExercise: async (exerciseId, muscle) => {
+    set({ isShuffling: true });
+    try {
+      const { selectedEquipment, exercisesByMuscle } = get();
+
+      const allExerciseIds = exercisesByMuscle.flatMap((group) => group.exercises.map((ex: any) => ex.id));
+
+      const result = await shuffleExerciseAction({
+        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;
+          }),
+          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>
 

+ 19 - 5
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,10 +30,11 @@ export const ExercisesSelection = ({
   onPick,
   onDelete,
   onAdd,
+  isShuffling,
 }: ExercisesSelectionProps) => {
   const t = useI18n();
   const [flatExercises, setFlatExercises] = useState<{ id: string; muscle: string; exercise: ExerciseWithAttributes }[]>([]);
-  const { setExercisesOrder } = useWorkoutStepper();
+  const { setExercisesOrder, exercisesOrder } = useWorkoutStepper();
   const sensors = useSensors(
     useSensor(PointerSensor, {
       activationConstraint: {
@@ -50,11 +52,22 @@ export const ExercisesSelection = ({
           exercise,
         })),
       );
-      setFlatExercises(flat);
+
+      // if exerciseOrder is not empty, we need to order the exercises
+      if (exercisesOrder.length > 0) {
+        const orderedFlat = exercisesOrder.map((id) => flat.find((item) => item.id === id)).filter(Boolean) as typeof flat;
+
+        // add new exercises that are not in exercisesOrder
+        const newExercises = flat.filter((item) => !exercisesOrder.includes(item.id));
+
+        setFlatExercises([...orderedFlat, ...newExercises]);
+      } else {
+        setFlatExercises(flat);
+      }
     } else {
       setFlatExercises([]);
     }
-  }, [exercisesByMuscle]);
+  }, [exercisesByMuscle, exercisesOrder]);
 
   const handleDragEnd = (event: DragEndEvent) => {
     const { active, over } = event;
@@ -89,10 +102,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}

+ 53 - 0
src/features/workout-session/actions/delete-workout-session.action.ts

@@ -0,0 +1,53 @@
+"use server";
+
+import { z } from "zod";
+
+import { prisma } from "@/shared/lib/prisma";
+import { actionClient } from "@/shared/api/safe-actions";
+import { serverAuth } from "@/entities/user/model/get-server-session-user";
+
+const deleteWorkoutSessionSchema = z.object({
+  id: z.string(),
+});
+
+export const deleteWorkoutSessionAction = actionClient.schema(deleteWorkoutSessionSchema).action(async ({ parsedInput }) => {
+  try {
+    const user = await serverAuth();
+    const { id } = parsedInput;
+
+    if (!user) {
+      console.error("❌ User not authenticated");
+      return { serverError: "NOT_AUTHENTICATED" };
+    }
+
+    // Vérifier que la session appartient à l'utilisateur
+    const session = await prisma.workoutSession.findUnique({
+      where: { id },
+      select: { userId: true },
+    });
+
+    if (!session) {
+      console.error("❌ Session not found:", id);
+      return { serverError: "Session not found" };
+    }
+
+    if (session.userId !== user.id) {
+      console.error("❌ Unauthorized access to session:", id);
+      return { serverError: "Unauthorized" };
+    }
+
+    // Supprimer la session (cascade supprimera automatiquement les exercices et sets)
+    await prisma.workoutSession.delete({
+      where: { id },
+    });
+
+    if (process.env.NODE_ENV === "development") {
+      console.log("✅ Workout session deleted successfully:", id);
+    }
+
+    return { success: true };
+  } catch (error) {
+    console.error("❌ Error deleting workout session:", error);
+    return { serverError: "Failed to delete workout session" };
+  }
+});

+ 15 - 10
src/features/workout-session/ui/workout-session-list.tsx

@@ -2,6 +2,7 @@ import { useRouter } from "next/navigation";
 import { Play, Repeat2, Trash2 } from "lucide-react";
 
 import { useCurrentLocale, useI18n } from "locales/client";
+import { useWorkoutSessionService } from "@/shared/lib/workout-session/use-workout-session.service";
 import { useWorkoutSessions } from "@/features/workout-session/model/use-workout-sessions";
 import { useWorkoutBuilderStore } from "@/features/workout-builder/model/workout-builder.store";
 import { Link } from "@/components/ui/link";
@@ -21,24 +22,28 @@ export function WorkoutSessionList() {
   const t = useI18n();
   const router = useRouter();
   const loadFromSession = useWorkoutBuilderStore((s) => s.loadFromSession);
+  const { remove } = useWorkoutSessionService();
 
-  // const [sessions, setSessions] = useState<WorkoutSession[]>(() =>
-  //   workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()),
-  // );
-
-  const { data: sessions = [] } = useWorkoutSessions();
+  const { data: sessions = [], refetch } = useWorkoutSessions();
   const activeSession = sessions.find((s) => s.status === "active");
 
-  const handleDelete = (_id: string) => {
-    // TODO: delete by service
-    // workoutSessionLocal.remove(id);
+  const handleDelete = async (id: string) => {
+    const confirmed = window.confirm(t("workout_builder.confirm_delete"));
+
+    if (!confirmed) return;
+
+    try {
+      await remove(id);
+      refetch();
+    } catch (error) {
+      console.error("Error deleting session:", error);
+      alert("Error deleting session");
+    }
   };
 
   const handleRepeat = (id: string) => {
     const sessionToCopy = sessions.find((s) => s.id === id);
     if (!sessionToCopy) return;
-    // prepare data for the builder
-    console.log("sessionToCopy.exercises:", sessionToCopy.exercises);
 
     const allEquipment = Array.from(
       new Set(

+ 7 - 6
src/shared/lib/workout-session/use-workout-session.service.ts

@@ -1,6 +1,7 @@
 import { nullToUndefined } from "@/shared/lib/format";
 import { syncWorkoutSessionAction } from "@/features/workout-session/actions/sync-workout-sessions.action";
 import { getWorkoutSessionsAction } from "@/features/workout-session/actions/get-workout-sessions.action";
+import { deleteWorkoutSessionAction } from "@/features/workout-session/actions/delete-workout-session.action";
 import { useSession } from "@/features/auth/lib/auth-client";
 
 import { workoutSessionLocal } from "./workout-session.local";
@@ -94,12 +95,12 @@ export const useWorkoutSessionService = () => {
   };
 
   const remove = async (id: string) => {
-    // if (isUserLoggedIn()) {
-    //   // TODO: Créer une action deleteWorkoutSessionAction
-    //   const result = await deleteWorkoutSessionAction({ id });
-    //   if (result.serverError) throw new Error(result.serverError);
-    // }
-    // workoutSessionLocal.remove(id);
+    if (userId) {
+      const result = await deleteWorkoutSessionAction({ id });
+      if (result?.serverError) throw new Error(result.serverError);
+    }
+
+    workoutSessionLocal.remove(id);
   };
 
   return { getAll, add, update, complete, remove };