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

feat/start workout (#12)

* feat(workout-builder): implement workout session management with session state, timer, and progress tracking
feat(workout-builder): add quit workout dialog for saving or discarding progress
feat(workout-builder): enhance workout stepper with session header and exercise tracking features
feat(locales): add English and French translations for workout session messages and prompts

* refactor(workout-stepper.tsx): simplify exercise selection logic by removing pickedExercises state and using exercisesByMuscle for validation
fix(workout-stepper.tsx): update button disabled state to reflect the presence of exercises instead of pickedExercises

* refactor(workout-stepper.tsx): clean up code by removing commented-out sections and simplifying map functions for better readability and maintainability

* feat(prisma): add workout session tracking with related tables and enums for sets and units to enhance workout data management

* feat: display exercices

* feat: display exercices 2

* refactor(workout-session): update workout session logic to use exercise index for set operations and improve UI layout for better user experience

* feat(workout-builder): integrate WorkoutSessionHeader component into WorkoutStepper and enhance exercise selection in WorkoutExerciseSets
refactor(workout-session): streamline WorkoutSetRow component by consolidating input handling for exercise types and improving UI responsiveness

* feat(workout-session): add goToExercise function to navigate between exercises and simplify exercise selection logic in WorkoutExerciseSets component

* feat(workout-set): add multi-column support for WorkoutSet model to enhance flexibility in workout data representation

feat(workout-set): update schema, migration, and UI components to accommodate new multi-column structure for types, values, and units in WorkoutSet

* feat(locales): add new localization strings for workout session actions in English and French to enhance user experience
style(WorkoutExerciseSets.tsx, WorkoutSetRow.tsx): update button text retrieval to use localization strings and change button size prop for consistency

* refactor(WorkoutSetRow): update input styles for better readability and consistency, change default value for empty inputs to 0, and improve layout for better responsiveness

* style(WorkoutExerciseSets.tsx): remove unnecessary border from detail div for cleaner UI
feat(utils.ts): add utility function cn to merge Tailwind CSS classes for better styling management

* style(WorkoutSetRow.tsx): update layout and styling for better responsiveness and alignment in the workout set row component

* style(WorkoutExerciseSets.tsx, WorkoutSetRow.tsx): update styling for improved layout and spacing in workout session components

* feat(WorkoutSetRow): integrate i18n for button label localization to enhance user experience and support multiple languages
style(WorkoutSetRow): update button styles for improved aesthetics and consistency in the UI

* feat(WorkoutSetRow): add delete button to remove workout sets and improve layout for better usability

* style(locales/fr.ts): correct capitalization in French translations for consistency
style(workout-session-header.tsx): update styles for improved layout and readability in the workout session header
style(WorkoutExerciseSets.tsx): adjust padding and heading styles for better visual consistency in the workout exercise sets component

* style(workout-session-header.tsx): update background color from white to slate-50 for improved UI aesthetics

* refactor(workout-session): clean up commented code and improve session logging for better debugging
feat(workout-exercise-sets): enhance exercise step rendering with visual indicators for completed sets and current exercise

* feat(workout-stepper): add startWorkout function to workout session for enhanced workout management

* feat(locales): add new workout session terms for bodyweight, weight, reps, and time in English and French translations
refactor(workout-session): update workout set model to support multiple types and adjust UI to handle new types for workout sets

* feat(workout-builder): remove unused Dumbbell icon and refactor WorkoutSessionHeader for cleaner UI
feat(workout-stepper): integrate WorkoutSessionHeader to display current exercise details during workout
feat(workout-set-row): add edit functionality for completed workout sets and improve button layout for better UX

* feat(workout-session): implement workout session management with new components for session header, sets, and schema to enhance user experience and functionality in workout tracking.
refactor(workout-builder): update stepper components to utilize new session management structure and improve code organization.
fix(workout-session): correct import paths and component references to ensure proper functionality and maintainability.

* refactor(workout-builder): update prop types to use VoidFunction for better clarity and consistency
style(workout-session): clean up commented code and improve button styling for better readability

* feat(locales): add "go to profile" text in English and French translations for improved user navigation
feat(package): add canvas-confetti library for celebratory animations in workout sessions
feat(workout-session): implement workout session management with local storage and synchronization capabilities
feat(workout-stepper): enhance workout session completion flow with congratulatory message and confetti effect
feat(workout-session-sets): display completion message and navigation option to profile after finishing workout session
feat(network): add useNetworkStatus

* feat(profile, workout-builder, workout-sessions): add new pages for profile, workout builder, and workout sessions with corresponding components and logic
fix(button): increase icon size in button component for better visibility
feat(workout-session): implement local storage management for workout sessions with CRUD operations and synchronization with a potential API
refactor(use-workout-session): update session management to utilize local storage and improve session handling logic
style(Calendar): enhance calendar component for better responsiveness and usability
feat(Calendar): create a

* feat(workout-session-list): add functionality to repeat workout sessions by copying exercises and resetting necessary fields

* style(button.tsx, release-notes-dialog.tsx, workout-session-list.tsx): update button styles and layout for improved UI consistency and accessibility
feat(workout-session-list.tsx): add session start and end time display for better user information

* feat(workout-sessions): add workout session heatmap and calendar components to visualize training history
chore(package): add react-github-contribution-calendar dependency for heatmap functionality

* feat(workout-sessions): enhance WorkoutSessionHeatmap with tooltip functionality for better user interaction and visibility of workout data
chore(workout-sessions): comment out unused WorkoutSessionList component in WorkoutSessionsPage for cleaner code and future reference
style(workout-sessions): adjust panel size and margin in WorkoutSessionHeatmap for improved layout and visual appeal

* feat(workout-sessions): uncomment WorkoutSessionList component to enable session selection functionality in the UI

* refactor(workout-session): add TODO comments for internationalization and improve code readability with consistent comments
style(workout-session): adjust margin of Workout History header for better layout consistency

* feat(profile): refactor profile page to utilize workout session data and remove calendar component
chore(workout-sessions): delete unused workout sessions page
feat(header): add home icon with tooltip to header for better navigation
chore(calendar): remove calendar component as it is no longer used
fix(release-notes): add tooltip to release notes button for improved accessibility
refactor(workout-builder): streamline workout session handling and remove unused code in workout stepper and session sets components

* style(exercise-list-item.tsx): adjust icon sizes for Shuffle and Star buttons to ensure consistent UI appearance

* style(Header.tsx): reorder InlineTooltip and Link components for improved readability and structure

* feat(workout-builder): implement drag-and-drop functionality for exercise list using dnd-kit to enhance user experience in workout management
fix(workout-builder): refactor exercise list rendering to use flatExercises state for better performance and maintainability

* feat(workout-stepper): implement exercise order persistence in localStorage during drag-and-drop reordering to enhance user experience
refactor(workout-stepper): update allExercises calculation to use flatExercises for better clarity and maintainability

* feat(workout-builder): refactor ExerciseVideoModal imports for better organization and add video modal functionality in WorkoutSessionSets for enhanced user experience

* feat(workout-session-sets): add introduction link to display exercise instructions and open video modal for better user guidance

* style(workout-session-sets.tsx): update className to use aspect-video for better video aspect ratio handling in UI

* refactor(exercise-list-item.tsx, exercise-video-modal.tsx, workout-session-sets.tsx): simplify ExerciseVideoModal usage by passing the entire exercise object instead of individual props for better maintainability and clarity

* feat(exercise-video-modal): enhance video modal with introduction and description, and add attribute badges for better exercise context
style(workout-session-sets): add hover effect to exercise image container for improved user interaction

* refactor(workout-session): enhance type definitions for exercises and sets to improve type safety and clarity in workout session management

* fix(workout-session-list.tsx): update id generation for exercises and sets to use existing id for consistency and avoid potential conflicts

* refactor(workout-stepper.tsx): simplify exercise object creation in workout stepper to enhance readability and maintainability

* style(Header.tsx): remove underline from profile, login, and register links for improved UI consistency
refactor(workout-session-sets.tsx): simplify exercise details mapping by using session exercises directly instead of workout stepper

* feat(workout-stepper): add Next step button to NavigationFooter for improved user navigation in the workout builder

* feat(workout-session): add progress tracking for workout sessions and update UI components to reflect progress
refactor(locales): remove unused translation key for time spent in English and French locales
fix(workout-stepper): pass session ID instead of total exercises to improve data handling in workout stepper component

* feat(workout-builder): refactor workout stepper logic into Zustand store for better state management and simplify component structure
refactor(workout-builder): remove unused workout session hooks to clean up codebase

* refactor(workout-session): extract workout session logic into a Zustand store for better state management and cleaner code structure

* refactor(workout-builder): move useWorkoutSession hook to workout-session feature for better modularity and organization
feat(workout-session): implement useWorkoutSession hook and workout session store to manage workout state and session data effectively

* feat(profile): enhance workout session features with localization support and new session management
fix(workout-session): improve session display and interaction with localized text for better user experience

* feat(workout-session-sets): enhance localization by adding useCurrentLocale hook and updating exercise name display based on locale
style(workout-session-sets): adjust image scale and alt text for better accessibility and visual consistency

* feat(workout): implement session loading from local storage in WorkoutStepper and remove WorkoutBuilderPage for better structure and clarity

* feat(workout-builder): add ExercisesSelection and WorkoutBuilderFooter components to enhance workout selection and navigation experience
refactor(workout-stepper): simplify workout stepper by integrating new components and removing redundant code for better maintainability

* feat(workout-builder): add exercises order management to enhance workout customization and improve user experience
fix(workout-session): update session state to handle completion of sets and transition to the next exercise correctly

* feat(workout-session): add methods to get completed and total exercises for better session tracking
fix(workout-session-header): update to use new methods for exercises completed and total exercises
style(workout-session-set): replace hardcoded text with translation for edit button

* refactor(workout-session): remove unused sessionId prop from WorkoutSessionHeader and WorkoutStepper components to simplify props
refactor(workout-session): streamline useWorkoutSession hook usage in WorkoutSessionSets component for better readability and maintainability

* feat(exercises-selection): replace loading spinner with Loader2 component for better visual feedback
fix(workout-session.store): reset workout builder step to 1 upon completing a workout session to ensure correct state management

* feat(locales): add "Finish Session" text in English and French translations for better user experience
feat(workout-session): enhance workout session UI with progress calculation and improved button designs for better usability

* feat(locales): add translation for "Finish Set" in English and French
feat(workout-session): update button label to use localized "Finish Set" text for better user experience

* fix(workout-session.store.ts): remove redundant comment about setting current step and improve exercise completion logic to only count exercises with sets
Mat B. пре 1 месец
родитељ
комит
013a24ad82
42 измењених фајлова са 2633 додато и 341 уклоњено
  1. 1 0
      app/[locale]/page.tsx
  2. 34 0
      app/[locale]/profile/page.tsx
  3. 38 0
      locales/en.ts
  4. 39 0
      locales/fr.ts
  5. 3 0
      package.json
  6. 63 0
      pnpm-lock.yaml
  7. 52 0
      prisma/migrations/20250612213546_workout_session_sets/migration.sql
  8. 5 0
      prisma/migrations/20250613095031_add_multi_column_support/migration.sql
  9. 56 5
      prisma/schema.prisma
  10. BIN
      public/images/trophy.png
  11. 1 1
      src/components/ui/button.tsx
  12. 13 5
      src/features/layout/Header.tsx
  13. 5 3
      src/features/release-notes/ui/release-notes-dialog.tsx
  14. 1 0
      src/features/workout-builder/index.ts
  15. 9 0
      src/features/workout-builder/model/use-workout-session.ts
  16. 29 83
      src/features/workout-builder/model/use-workout-stepper.ts
  17. 77 0
      src/features/workout-builder/model/workout-builder.store.ts
  18. 12 1
      src/features/workout-builder/types/index.ts
  19. 19 10
      src/features/workout-builder/ui/exercise-list-item.tsx
  20. 67 20
      src/features/workout-builder/ui/exercise-video-modal.tsx
  21. 143 0
      src/features/workout-builder/ui/exercises-selection.tsx
  22. 92 0
      src/features/workout-builder/ui/quit-workout-dialog.tsx
  23. 0 14
      src/features/workout-builder/ui/stepper-header.tsx
  24. 124 0
      src/features/workout-builder/ui/workout-stepper-footer.tsx
  25. 116 199
      src/features/workout-builder/ui/workout-stepper.tsx
  26. 7 0
      src/features/workout-session/model/use-workout-session.ts
  27. 323 0
      src/features/workout-session/model/workout-session.store.ts
  28. 17 0
      src/features/workout-session/schema/workout-session-set.schema.ts
  29. 35 0
      src/features/workout-session/types/workout-set.ts
  30. 49 0
      src/features/workout-session/ui/workout-session-calendar.tsx
  31. 169 0
      src/features/workout-session/ui/workout-session-header.tsx
  32. 193 0
      src/features/workout-session/ui/workout-session-heatmap.tsx
  33. 131 0
      src/features/workout-session/ui/workout-session-list.tsx
  34. 236 0
      src/features/workout-session/ui/workout-session-set.tsx
  35. 221 0
      src/features/workout-session/ui/workout-session-sets.tsx
  36. 19 0
      src/shared/lib/network/use-network-status.ts
  37. 99 0
      src/shared/lib/storage/workout-session-storage.ts
  38. 16 0
      src/shared/lib/workout-session/types/workout-session.ts
  39. 18 0
      src/shared/lib/workout-session/workout-session.api.ts
  40. 58 0
      src/shared/lib/workout-session/workout-session.local.ts
  41. 28 0
      src/shared/lib/workout-session/workout-session.service.ts
  42. 15 0
      src/shared/lib/workout-session/workout-session.sync.ts

+ 1 - 0
app/[locale]/page.tsx

@@ -7,6 +7,7 @@ import { serverAuth } from "@/entities/user/model/get-server-session-user";
 export default async function HomePage() {
   const user = await serverAuth();
   const t = await getI18n();
+
   return (
     <div className="bg-background text-foreground relative flex h-fit flex-col">
       <WorkoutStepper />

+ 34 - 0
app/[locale]/profile/page.tsx

@@ -0,0 +1,34 @@
+"use client";
+import { useRouter } from "next/navigation";
+
+import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { WorkoutSessionList } from "@/features/workout-session/ui/workout-session-list";
+import { WorkoutSessionHeatmap } from "@/features/workout-session/ui/workout-session-heatmap";
+import { Button } from "@/components/ui/button";
+
+export default function ProfilePage() {
+  const router = useRouter();
+
+  const sessions = typeof window !== "undefined" ? workoutSessionLocal.getAll() : [];
+  const values: Record<string, number> = {};
+  sessions.forEach((session) => {
+    const date = session.startedAt.slice(0, 10);
+    values[date] = (values[date] || 0) + 1;
+  });
+  const until =
+    sessions.length > 0
+      ? sessions.reduce((max, s) => (s.startedAt > max ? s.startedAt : max), sessions[0].startedAt).slice(0, 10)
+      : new Date().toISOString().slice(0, 10);
+
+  return (
+    <div>
+      <WorkoutSessionHeatmap until={until} values={values} />
+      <WorkoutSessionList onSelect={(id) => router.push(`/workout-builder?sessionId=${id}`)} />
+      <div className="mt-8 flex justify-center">
+        <Button onClick={() => router.push("/workout-builder")} size="large">
+          Nouvelle séance
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 38 - 0
locales/en.ts

@@ -152,6 +152,43 @@ export default {
       exercise_selection_coming_soon: "Exercise Selection (Coming Soon)",
       exercise_selection_description: "This step will show you personalized exercise recommendations.",
     },
+    session: {
+      finish_set: "Finish Set",
+      finish_session: "Finish Session",
+      bodyweight: "Bodyweight",
+      weight: "Weight",
+      reps: "Reps",
+      time: "Time",
+      next_exercise: "Next Exercise",
+      add_set: "Add set",
+      add_column: "Add column",
+      remove_column: "Remove column",
+      set_number: "Set {number}",
+      set_number_plural: "Sets {number}",
+      set_number_singular: "Set {number}",
+      set_number_plural_singular: "Sets {number}",
+      workout_in_progress: "Workout in Progress",
+      quit_workout: "Quit Workout",
+      elapsed_time: "Elapsed Time",
+      exercise_progress: "Exercise Progress",
+      current_exercise: "Current Exercise",
+      complete: "Complete",
+      active: "Active",
+      no_exercise_selected: "No exercise selected",
+      quit_workout_title: "Quit Workout?",
+      progress: "Progress",
+      quit_warning: "Are you sure you want to quit? You can save your progress or lose it completely.",
+      save_and_quit: "Save & Quit",
+      quit_without_save: "Quit Without Saving",
+      continue_workout: "Continue Workout",
+      history: "Workout History [{count}]",
+      no_workout_yet: "No workout yet.",
+      start: "start",
+      end: "end",
+      exercise: "EXERCISE",
+      repeat: "Repeat",
+      delete: "Delete",
+    },
   },
   commons: {
     signup_with: "Sign up with {provider}",
@@ -235,6 +272,7 @@ export default {
     looks_like_you_are_lost: "Looks like you are lost",
     the_page_you_are_looking_for_is_not_available: "The page you are looking for is not available",
     go_to_home: "Go to home",
+    go_to_profile: "Go to profile",
     terms: "Terms of Service",
     privacy: "Privacy Policy",
     sales_terms: "Sales Terms",

+ 39 - 0
locales/fr.ts

@@ -152,6 +152,44 @@ export default {
       exercise_selection_coming_soon: "Sélection des exercices (Bientôt disponible)",
       exercise_selection_description: "Cette étape vous montrera des recommandations d'exercices personnalisées.",
     },
+    session: {
+      finish_set: "Valider la série",
+      finish_session: "Terminer la séance",
+      bodyweight: "Poids du corps",
+      weight: "Poids",
+      reps: "Répétitions",
+      time: "Temps",
+      next_exercise: "Exercice suivant",
+      add_set: "Ajouter une série",
+      add_column: "Ajouter une colonne",
+      remove_column: "Supprimer une colonne",
+      set_number: "Série {number}",
+      set_number_plural: "Séries {number}",
+      set_number_singular: "Série {number}",
+      set_number_plural_singular: "Séries {number}",
+      workout_in_progress: "Entraînement en cours",
+      quit_workout: "Quitter l'Entraînement",
+      elapsed_time: "Temps écoulé",
+      total_workout_time: "Temps total d'entraînement",
+      exercise_progress: "Progression",
+      current_exercise: "Exercice actuel",
+      complete: "Terminé",
+      active: "Actif",
+      no_exercise_selected: "Aucun exercice sélectionné",
+      quit_workout_title: "Quitter l'Entraînement ?",
+      progress: "Progression",
+      quit_warning: "Êtes-vous sûr de vouloir quitter ? Vous pouvez sauvegarder votre progression ou la perdre complètement.",
+      save_and_quit: "Sauvegarder & Quitter",
+      quit_without_save: "Quitter Sans Sauvegarder",
+      continue_workout: "Continuer l'Entraînement",
+      history: "Historique des séances [{count}]",
+      no_workout_yet: "Aucune séance enregistrée.",
+      start: "début",
+      end: "fin",
+      exercise: "EXERCICE",
+      repeat: "Répéter",
+      delete: "Supprimer",
+    },
   },
   commons: {
     signup_with: "S'inscrire avec {provider}",
@@ -235,6 +273,7 @@ export default {
     looks_like_you_are_lost: "Il semble que vous soyez perdu",
     the_page_you_are_looking_for_is_not_available: "La page que vous cherchez n'est pas disponible",
     go_to_home: "Retour à l'accueil",
+    go_to_profile: "Aller à mon profil",
     terms: "Conditions d'utilisation",
     privacy: "Politique de confidentialité",
     sales_terms: "Conditions de vente",

+ 3 - 0
package.json

@@ -68,6 +68,7 @@
     "@tiptap/starter-kit": "^2.11.7",
     "@vercel/functions": "^2.0.3",
     "better-auth": "^1.2.7",
+    "canvas-confetti": "^1.9.3",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "csv-parser": "^3.2.0",
@@ -104,6 +105,7 @@
     "react-colorful": "^5.6.1",
     "react-dom": "^19.0.0",
     "react-facebook-pixel": "^1.0.4",
+    "react-github-contribution-calendar": "^2.2.0",
     "react-hook-form": "^7.55.0",
     "react-icons": "^5.5.0",
     "react-qrcode-logo": "^3.0.0",
@@ -122,6 +124,7 @@
     "@eslint/eslintrc": "^3.3.1",
     "@eslint/js": "^9.28.0",
     "@next/eslint-plugin-next": "^15.2.4",
+    "@types/canvas-confetti": "^1.9.0",
     "@types/lodash.debounce": "^4.0.9",
     "@types/lodash.set": "^4.3.9",
     "@types/node": "^20",

+ 63 - 0
pnpm-lock.yaml

@@ -158,6 +158,9 @@ importers:
       better-auth:
         specifier: ^1.2.7
         version: 1.2.9
+      canvas-confetti:
+        specifier: ^1.9.3
+        version: 1.9.3
       class-variance-authority:
         specifier: ^0.7.1
         version: 0.7.1
@@ -266,6 +269,9 @@ importers:
       react-facebook-pixel:
         specifier: ^1.0.4
         version: 1.0.4
+      react-github-contribution-calendar:
+        specifier: ^2.2.0
+        version: 2.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       react-hook-form:
         specifier: ^7.55.0
         version: 7.57.0(react@19.1.0)
@@ -315,6 +321,9 @@ importers:
       '@next/eslint-plugin-next':
         specifier: ^15.2.4
         version: 15.3.3
+      '@types/canvas-confetti':
+        specifier: ^1.9.0
+        version: 1.9.0
       '@types/lodash.debounce':
         specifier: ^4.0.9
         version: 4.0.9
@@ -587,6 +596,10 @@ packages:
     resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
     engines: {node: '>=6.9.0'}
 
+  '@babel/runtime@7.27.6':
+    resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
+    engines: {node: '>=6.9.0'}
+
   '@better-auth/utils@0.2.5':
     resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==}
 
@@ -2428,6 +2441,9 @@ packages:
   '@tybys/wasm-util@0.9.0':
     resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
 
+  '@types/canvas-confetti@1.9.0':
+    resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
+
   '@types/debug@4.1.12':
     resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
 
@@ -2836,6 +2852,9 @@ packages:
   caniuse-lite@1.0.30001721:
     resolution: {integrity: sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==}
 
+  canvas-confetti@1.9.3:
+    resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==}
+
   ccount@2.0.1:
     resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
 
@@ -3394,6 +3413,9 @@ packages:
     resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
     engines: {node: '>= 0.4'}
 
+  get-node-dimensions@1.2.1:
+    resolution: {integrity: sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==}
+
   get-nonce@1.0.1:
     resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
     engines: {node: '>=6'}
@@ -4550,6 +4572,11 @@ packages:
   react-facebook-pixel@1.0.4:
     resolution: {integrity: sha512-givZY8MS0v/mdbRzvcvouBo/j0TtDiu/93f4gIjJXwDDgwlf6bYUiQvb2qcqjluOOD/hIKUQHNYLNsSOnoEklg==}
 
+  react-github-contribution-calendar@2.2.0:
+    resolution: {integrity: sha512-dTpKjsMX/9qyfPC24h3Y0yGxfoOCyp9xBcVvpRe5e98/ikcAW0oIaO6DLfB9byOdN9Y3wovcQ6fHupmZF8NdjA==}
+    peerDependencies:
+      react: ^16 || ^17 || ^18
+
   react-hook-form@7.57.0:
     resolution: {integrity: sha512-RbEks3+cbvTP84l/VXGUZ+JMrKOS8ykQCRYdm5aYsxnDquL0vspsyNhGRO7pcH6hsZqWlPOjLye7rJqdtdAmlg==}
     engines: {node: '>=18.0.0'}
@@ -4564,6 +4591,12 @@ packages:
   react-is@16.13.1:
     resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
 
+  react-measure@2.5.2:
+    resolution: {integrity: sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==}
+    peerDependencies:
+      react: '>0.13.0'
+      react-dom: '>0.13.0'
+
   react-promise-suspense@0.3.4:
     resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
 
@@ -4650,6 +4683,9 @@ packages:
     resolution: {integrity: sha512-Uu11/254nkDFgVXQp18rzuz+9kRy5Ud4qr7FW98Yg4I4jkDKX1cr/8JKdrcJI753oknEq69/i3VTLbtrveQUGw==}
     engines: {node: '>=18'}
 
+  resize-observer-polyfill@1.5.1:
+    resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
+
   resolve-from@4.0.0:
     resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
     engines: {node: '>=4'}
@@ -5739,6 +5775,8 @@ snapshots:
 
   '@babel/helper-validator-identifier@7.27.1': {}
 
+  '@babel/runtime@7.27.6': {}
+
   '@better-auth/utils@0.2.5':
     dependencies:
       typescript: 5.8.3
@@ -7618,6 +7656,8 @@ snapshots:
       tslib: 2.8.1
     optional: true
 
+  '@types/canvas-confetti@1.9.0': {}
+
   '@types/debug@4.1.12':
     dependencies:
       '@types/ms': 2.1.0
@@ -8053,6 +8093,8 @@ snapshots:
 
   caniuse-lite@1.0.30001721: {}
 
+  canvas-confetti@1.9.3: {}
+
   ccount@2.0.1: {}
 
   chalk@4.1.2:
@@ -8775,6 +8817,8 @@ snapshots:
       hasown: 2.0.2
       math-intrinsics: 1.1.0
 
+  get-node-dimensions@1.2.1: {}
+
   get-nonce@1.0.1: {}
 
   get-proto@1.0.1:
@@ -10083,6 +10127,14 @@ snapshots:
 
   react-facebook-pixel@1.0.4: {}
 
+  react-github-contribution-calendar@2.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+    dependencies:
+      dayjs: 1.11.13
+      react: 19.1.0
+      react-measure: 2.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+    transitivePeerDependencies:
+      - react-dom
+
   react-hook-form@7.57.0(react@19.1.0):
     dependencies:
       react: 19.1.0
@@ -10093,6 +10145,15 @@ snapshots:
 
   react-is@16.13.1: {}
 
+  react-measure@2.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+    dependencies:
+      '@babel/runtime': 7.27.6
+      get-node-dimensions: 1.2.1
+      prop-types: 15.8.1
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+      resize-observer-polyfill: 1.5.1
+
   react-promise-suspense@0.3.4:
     dependencies:
       fast-deep-equal: 2.0.1
@@ -10230,6 +10291,8 @@ snapshots:
       - react
       - react-dom
 
+  resize-observer-polyfill@1.5.1: {}
+
   resolve-from@4.0.0: {}
 
   resolve-pkg-maps@1.0.0: {}

+ 52 - 0
prisma/migrations/20250612213546_workout_session_sets/migration.sql

@@ -0,0 +1,52 @@
+-- CreateEnum
+CREATE TYPE "WorkoutSetType" AS ENUM ('TIME', 'WEIGHT', 'REPS', 'BODYWEIGHT', 'NA');
+
+-- CreateEnum
+CREATE TYPE "WorkoutSetUnit" AS ENUM ('kg', 'lbs');
+
+-- CreateTable
+CREATE TABLE "WorkoutSession" (
+    "id" TEXT NOT NULL,
+    "userId" TEXT NOT NULL,
+    "startedAt" TIMESTAMP(3) NOT NULL,
+    "endedAt" TIMESTAMP(3),
+    "duration" INTEGER,
+
+    CONSTRAINT "WorkoutSession_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "WorkoutSessionExercise" (
+    "id" TEXT NOT NULL,
+    "workoutSessionId" TEXT NOT NULL,
+    "exerciseId" TEXT NOT NULL,
+    "order" INTEGER NOT NULL,
+
+    CONSTRAINT "WorkoutSessionExercise_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "WorkoutSet" (
+    "id" TEXT NOT NULL,
+    "workoutSessionExerciseId" TEXT NOT NULL,
+    "setIndex" INTEGER NOT NULL,
+    "type" "WorkoutSetType" NOT NULL,
+    "valueInt" INTEGER,
+    "valueSec" INTEGER,
+    "unit" "WorkoutSetUnit",
+    "completed" BOOLEAN NOT NULL DEFAULT false,
+
+    CONSTRAINT "WorkoutSet_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "WorkoutSession" ADD CONSTRAINT "WorkoutSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkoutSessionExercise" ADD CONSTRAINT "WorkoutSessionExercise_workoutSessionId_fkey" FOREIGN KEY ("workoutSessionId") REFERENCES "WorkoutSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkoutSessionExercise" ADD CONSTRAINT "WorkoutSessionExercise_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "exercises"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WorkoutSet" ADD CONSTRAINT "WorkoutSet_workoutSessionExerciseId_fkey" FOREIGN KEY ("workoutSessionExerciseId") REFERENCES "WorkoutSessionExercise"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

+ 5 - 0
prisma/migrations/20250613095031_add_multi_column_support/migration.sql

@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "WorkoutSet" ADD COLUMN     "types" "WorkoutSetType"[] DEFAULT ARRAY[]::"WorkoutSetType"[],
+ADD COLUMN     "units" "WorkoutSetUnit"[] DEFAULT ARRAY[]::"WorkoutSetUnit"[],
+ADD COLUMN     "valuesInt" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
+ADD COLUMN     "valuesSec" INTEGER[] DEFAULT ARRAY[]::INTEGER[];

+ 56 - 5
prisma/schema.prisma

@@ -30,10 +30,11 @@ model User {
   accounts      Account[]
   feedbacks     Feedbacks[]
 
-  role       UserRole? @default(user)
-  banned     Boolean?  @default(false)
-  banReason  String?
-  banExpires DateTime?
+  role           UserRole?        @default(user)
+  banned         Boolean?         @default(false)
+  banReason      String?
+  banExpires     DateTime?
+  WorkoutSession WorkoutSession[]
 
   @@map("user")
 }
@@ -114,7 +115,8 @@ model Exercise {
   updatedAt         DateTime @updatedAt
 
   // Relations
-  attributes ExerciseAttribute[]
+  attributes             ExerciseAttribute[]
+  WorkoutSessionExercise WorkoutSessionExercise[]
 
   @@map("exercises")
 }
@@ -263,3 +265,52 @@ enum ExerciseAttributeValueEnum {
   ISOLATION
   COMPOUND
 }
+
+model WorkoutSession {
+  id        String                   @id @default(cuid())
+  userId    String
+  user      User                     @relation(fields: [userId], references: [id])
+  startedAt DateTime
+  endedAt   DateTime?
+  duration  Int? // en secondes
+  exercises WorkoutSessionExercise[]
+}
+
+model WorkoutSessionExercise {
+  id               String         @id @default(cuid())
+  workoutSessionId String
+  exerciseId       String
+  order            Int
+  workoutSession   WorkoutSession @relation(fields: [workoutSessionId], references: [id])
+  exercise         Exercise       @relation(fields: [exerciseId], references: [id])
+  sets             WorkoutSet[]
+}
+
+model WorkoutSet {
+  id                       String                 @id @default(cuid())
+  workoutSessionExerciseId String
+  setIndex                 Int
+  type                     WorkoutSetType
+  types                    WorkoutSetType[]       @default([])
+  valueInt                 Int? // reps, poids, minutes, etc.
+  valuesInt                Int[]                  @default([])
+  valueSec                 Int? // secondes si TIME
+  valuesSec                Int[]                  @default([])
+  unit                     WorkoutSetUnit?
+  units                    WorkoutSetUnit[]       @default([])
+  completed                Boolean                @default(false)
+  workoutSessionExercise   WorkoutSessionExercise @relation(fields: [workoutSessionExerciseId], references: [id])
+}
+
+enum WorkoutSetType {
+  TIME
+  WEIGHT
+  REPS
+  BODYWEIGHT
+  NA
+}
+
+enum WorkoutSetUnit {
+  kg
+  lbs
+}

BIN
public/images/trophy.png


+ 1 - 1
src/components/ui/button.tsx

@@ -5,7 +5,7 @@ import { Slot } from "@radix-ui/react-slot";
 import { cn } from "@/shared/lib/utils";
 
 const buttonVariants = cva(
-  "hover:scale-[0.98] inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-2 text-center text-xs/4 font-medium outline-none transition duration-300 disabled:pointer-events-none disabled:opacity-30 disabled:hover:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0",
+  "hover:scale-[0.98] inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 py-2 text-center text-xs/4 font-medium outline-none transition duration-300 disabled:pointer-events-none disabled:opacity-30 disabled:hover:cursor-not-allowed [&>svg]:shrink-0",
   {
     variants: {
       variant: {

+ 13 - 5
src/features/layout/Header.tsx

@@ -1,13 +1,14 @@
 "use client";
 
 import Image from "next/image";
-import { LogIn, UserPlus, LogOut, User } from "lucide-react";
+import { Home, LogIn, UserPlus, LogOut, User } from "lucide-react";
 
 import { useI18n } from "locales/client";
 import Logo from "@public/logo.png";
 import { ReleaseNotesDialog } from "@/features/release-notes";
 import { useLogout } from "@/features/auth/model/useLogout";
 import { useSession } from "@/features/auth/lib/auth-client";
+import { InlineTooltip } from "@/components/ui/tooltip";
 import { Link } from "@/components/ui/link";
 
 export const Header = () => {
@@ -25,7 +26,7 @@ export const Header = () => {
   return (
     <div className="navbar bg-base-100 px-4">
       {/* Logo and Title */}
-      <div className="navbar-start">
+      <div className="navbar-start flex items-center gap-2">
         <Link className="group flex items-center space-x-3 rounded-xl bg-gradient-to-r px-4 py-2 transition-all duration-200 " href="/">
           <div className="relative">
             <Image
@@ -45,7 +46,14 @@ export const Header = () => {
 
       {/* User Menu */}
       <div className="navbar-end">
+        <Link aria-label="Accueil" className="hover:bg-slate-100 rounded-full p-2 transition" href="/">
+          <InlineTooltip title="Accueil">
+            <Home className="w-6 h-6 text-blue-500" />
+          </InlineTooltip>
+        </Link>
+
         <ReleaseNotesDialog />
+
         <div className="dropdown dropdown-end">
           <div className="btn btn-ghost btn-circle avatar" role="button" tabIndex={0}>
             <div className="w-8 rounded-full bg-primary text-primary-content !flex items-center justify-center text-sm font-medium">
@@ -55,7 +63,7 @@ export const Header = () => {
 
           <ul className="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52" tabIndex={0}>
             <li>
-              <Link href="/profile" size="base" variant="nav">
+              <Link className="!no-underline" href="/profile" size="base" variant="nav">
                 {t("commons.profile")}
               </Link>
             </li>
@@ -65,13 +73,13 @@ export const Header = () => {
             {!session.data ? (
               <>
                 <li>
-                  <Link href="/auth/signin" size="base" variant="nav">
+                  <Link className="!no-underline" href="/auth/signin" size="base" variant="nav">
                     <LogIn className="w-4 h-4" />
                     {t("commons.login")}
                   </Link>
                 </li>
                 <li>
-                  <Link href="/auth/signup" size="base" variant="nav">
+                  <Link className="!no-underline" href="/auth/signup" size="base" variant="nav">
                     <UserPlus className="w-4 h-4" />
                     {t("commons.register")}
                   </Link>

+ 5 - 3
src/features/release-notes/ui/release-notes-dialog.tsx

@@ -5,6 +5,7 @@ import { Bell } from "lucide-react";
 
 import { useCurrentLocale, useI18n } from "locales/client";
 import { formatDate } from "@/shared/lib/date";
+import { InlineTooltip } from "@/components/ui/tooltip";
 import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
 import { Button } from "@/components/ui/button";
 
@@ -17,9 +18,10 @@ export function ReleaseNotesDialog() {
   return (
     <Dialog>
       <DialogTrigger asChild>
-        <Button aria-label={t("release_notes.release_notes")} size="icon" variant="ghost">
-          <span className="sr-only">{t("release_notes.release_notes")}</span>
-          <Bell className="h-4 w-4" />
+        <Button aria-label={t("release_notes.release_notes")} className="rounded-full hover:bg-slate-100" size="small" variant="ghost">
+          <InlineTooltip title="Annonces / Changelog">
+            <Bell className="text-blue-500 h-6 w-6" />
+          </InlineTooltip>
         </Button>
       </DialogTrigger>
       <DialogContent className="max-w-md">

+ 1 - 0
src/features/workout-builder/index.ts

@@ -1,4 +1,5 @@
 export { WorkoutStepper } from "./ui/workout-stepper";
 export { useWorkoutStepper } from "./model/use-workout-stepper";
+export { useWorkoutSession } from "../workout-session/model/use-workout-session";
 export { EQUIPMENT_CONFIG, getEquipmentByValue, getEquipmentLabel } from "./model/equipment-config";
 export type { WorkoutBuilderState, WorkoutBuilderStep, EquipmentItem } from "./types";

+ 9 - 0
src/features/workout-builder/model/use-workout-session.ts

@@ -0,0 +1,9 @@
+"use client";
+
+import { useWorkoutSessionStore } from "@/features/workout-session/model/workout-session.store";
+
+export function useWorkoutSession(sessionId?: string) {
+  // Le paramètre sessionId n'est plus utilisé ici, la logique de persistance reste dans workoutSessionLocal
+  // (si besoin, on peut l'utiliser pour charger une session spécifique dans le store)
+  return useWorkoutSessionStore();
+}

+ 29 - 83
src/features/workout-builder/model/use-workout-stepper.ts

@@ -1,87 +1,26 @@
 "use client";
 
-import { useCallback } from "react";
-import { useQueryState, parseAsInteger, parseAsArrayOf, parseAsString } from "nuqs";
-import { ExerciseAttributeValueEnum } from "@prisma/client";
-
-import { WorkoutBuilderStep } from "../types";
-import { useExercises } from "./use-exercises";
+import { useWorkoutBuilderStore } from "./workout-builder.store";
 
 export function useWorkoutStepper() {
-  // État persistant dans l'URL avec nuqs
-  const [currentStep, setCurrentStep] = useQueryState("step", parseAsInteger.withDefault(1));
-
-  const [selectedEquipment, setSelectedEquipment] = useQueryState("equipment", parseAsArrayOf(parseAsString).withDefault([]));
-
-  const [selectedMuscles, setSelectedMuscles] = useQueryState("muscles", parseAsArrayOf(parseAsString).withDefault([]));
-
-  // Récupération des exercices
   const {
-    data: exercisesByMuscle = [],
-    isLoading: isLoadingExercises,
-    error: exercisesError,
-  } = useExercises({
-    equipment: selectedEquipment as ExerciseAttributeValueEnum[],
-    muscles: selectedMuscles as ExerciseAttributeValueEnum[],
-    enabled: currentStep === 3, // Récupérer seulement à l'étape 3
-  });
-
-  // Navigation entre les étapes
-  const goToStep = useCallback(
-    (step: WorkoutBuilderStep) => {
-      setCurrentStep(step);
-    },
-    [setCurrentStep],
-  );
-
-  const nextStep = useCallback(() => {
-    if (currentStep < 3) {
-      setCurrentStep(currentStep + 1);
-    }
-  }, [currentStep, setCurrentStep]);
-
-  const prevStep = useCallback(() => {
-    if (currentStep > 1) {
-      setCurrentStep(currentStep - 1);
-    }
-  }, [currentStep, setCurrentStep]);
-
-  // Gestion des équipements
-  const toggleEquipment = useCallback(
-    (equipment: ExerciseAttributeValueEnum) => {
-      setSelectedEquipment((prev) => {
-        if (prev.includes(equipment)) {
-          return prev.filter((e) => e !== equipment);
-        } else {
-          return [...prev, equipment];
-        }
-      });
-    },
-    [setSelectedEquipment],
-  );
-
-  const clearEquipment = useCallback(() => {
-    setSelectedEquipment([]);
-  }, [setSelectedEquipment]);
-
-  // Gestion des muscles
-  const toggleMuscle = useCallback(
-    (muscle: ExerciseAttributeValueEnum) => {
-      console.log("muscle:", muscle);
-      setSelectedMuscles((prev) => {
-        if (prev.includes(muscle)) {
-          return prev.filter((m) => m !== muscle);
-        } else {
-          return [...prev, muscle];
-        }
-      });
-    },
-    [setSelectedMuscles],
-  );
-
-  const clearMuscles = useCallback(() => {
-    setSelectedMuscles([]);
-  }, [setSelectedMuscles]);
+    currentStep,
+    selectedEquipment,
+    selectedMuscles,
+    exercisesByMuscle,
+    isLoadingExercises,
+    exercisesError,
+    setStep,
+    nextStep,
+    prevStep,
+    toggleEquipment,
+    clearEquipment,
+    toggleMuscle,
+    clearMuscles,
+    fetchExercises,
+    exercisesOrder,
+    setExercisesOrder,
+  } = useWorkoutBuilderStore();
 
   // Validation des étapes
   const canProceedToStep2 = selectedEquipment.length > 0;
@@ -89,9 +28,9 @@ export function useWorkoutStepper() {
 
   return {
     // État
-    currentStep: currentStep as WorkoutBuilderStep,
-    selectedEquipment: selectedEquipment as ExerciseAttributeValueEnum[],
-    selectedMuscles: selectedMuscles as ExerciseAttributeValueEnum[],
+    currentStep,
+    selectedEquipment,
+    selectedMuscles,
 
     // Exercices
     exercisesByMuscle,
@@ -99,7 +38,7 @@ export function useWorkoutStepper() {
     exercisesError,
 
     // Navigation
-    goToStep,
+    goToStep: setStep,
     nextStep,
     prevStep,
 
@@ -114,5 +53,12 @@ export function useWorkoutStepper() {
     // Validation
     canProceedToStep2,
     canProceedToStep3,
+
+    // Fetch
+    fetchExercises,
+
+    // Order
+    exercisesOrder,
+    setExercisesOrder,
   };
 }

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

@@ -0,0 +1,77 @@
+import { create } from "zustand";
+import { ExerciseAttributeValueEnum } from "@prisma/client";
+
+import { WorkoutBuilderStep } from "../types";
+import { getExercisesAction } from "./get-exercises.action";
+
+interface WorkoutBuilderState {
+  currentStep: WorkoutBuilderStep;
+  selectedEquipment: ExerciseAttributeValueEnum[];
+  selectedMuscles: ExerciseAttributeValueEnum[];
+  // Exercices (groupés par muscle)
+  exercisesByMuscle: any[];
+  isLoadingExercises: boolean;
+  exercisesError: any;
+  exercisesOrder: string[];
+
+  // Actions
+  setStep: (step: WorkoutBuilderStep) => void;
+  nextStep: () => void;
+  prevStep: () => void;
+  toggleEquipment: (equipment: ExerciseAttributeValueEnum) => void;
+  clearEquipment: () => void;
+  toggleMuscle: (muscle: ExerciseAttributeValueEnum) => void;
+  clearMuscles: () => void;
+  fetchExercises: () => Promise<void>;
+  setExercisesOrder: (order: string[]) => void;
+}
+
+export const useWorkoutBuilderStore = create<WorkoutBuilderState>((set, get) => ({
+  currentStep: 1 as WorkoutBuilderStep,
+  selectedEquipment: [],
+  selectedMuscles: [],
+  exercisesByMuscle: [],
+  isLoadingExercises: false,
+  exercisesError: null,
+  exercisesOrder: [],
+
+  setStep: (step) => set({ currentStep: step }),
+  nextStep: () => set((state) => ({ currentStep: Math.min(state.currentStep + 1, 3) as WorkoutBuilderStep })),
+  prevStep: () => set((state) => ({ currentStep: Math.max(state.currentStep - 1, 1) as WorkoutBuilderStep })),
+
+  toggleEquipment: (equipment) =>
+    set((state) => ({
+      selectedEquipment: state.selectedEquipment.includes(equipment)
+        ? state.selectedEquipment.filter((e) => e !== equipment)
+        : [...state.selectedEquipment, equipment],
+    })),
+  clearEquipment: () => set({ selectedEquipment: [] }),
+
+  toggleMuscle: (muscle) =>
+    set((state) => ({
+      selectedMuscles: state.selectedMuscles.includes(muscle)
+        ? state.selectedMuscles.filter((m) => m !== muscle)
+        : [...state.selectedMuscles, muscle],
+    })),
+  clearMuscles: () => set({ selectedMuscles: [] }),
+
+  fetchExercises: async () => {
+    set({ isLoadingExercises: true, exercisesError: null });
+    try {
+      const { selectedEquipment, selectedMuscles } = get();
+      const result = await getExercisesAction({
+        equipment: selectedEquipment,
+        muscles: selectedMuscles,
+        limit: 3,
+      });
+      if (result?.serverError) {
+        throw new Error(result.serverError);
+      }
+      set({ exercisesByMuscle: result?.data || [], isLoadingExercises: false });
+    } catch (error) {
+      set({ exercisesError: error, isLoadingExercises: false });
+    }
+  },
+
+  setExercisesOrder: (order) => set({ exercisesOrder: order }),
+}));

+ 12 - 1
src/features/workout-builder/types/index.ts

@@ -1,5 +1,12 @@
 import { StaticImageData } from "next/image";
-import { ExerciseAttributeValueEnum, Exercise, ExerciseAttribute, ExerciseAttributeName, ExerciseAttributeValue } from "@prisma/client";
+import {
+  ExerciseAttributeValueEnum,
+  Exercise,
+  ExerciseAttribute,
+  ExerciseAttributeName,
+  ExerciseAttributeValue,
+  WorkoutSet,
+} from "@prisma/client";
 
 export interface WorkoutBuilderState {
   currentStep: number;
@@ -34,6 +41,10 @@ export type ExerciseWithAttributes = Exercise & {
   })[];
 };
 
+export type ExerciseWithAttributesAndSets = ExerciseWithAttributes & {
+  sets: WorkoutSet[];
+};
+
 export interface ExercisesByMuscle {
   muscle: ExerciseAttributeValueEnum;
   exercises: ExerciseWithAttributes[];

+ 19 - 10
src/features/workout-builder/ui/exercise-list-item.tsx

@@ -3,6 +3,8 @@
 import { useState } from "react";
 import Image from "next/image";
 import { Play, Shuffle, Star, Trash2, GripVertical } from "lucide-react";
+import { CSS } from "@dnd-kit/utilities";
+import { useSortable } from "@dnd-kit/sortable";
 
 import { useCurrentLocale, useI18n } from "locales/client";
 import { InlineTooltip } from "@/components/ui/tooltip";
@@ -28,6 +30,15 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
   const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
   const [showVideo, setShowVideo] = useState(false);
 
+  // dnd-kit sortable
+  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: exercise.id });
+
+  const style = {
+    transform: CSS.Transform.toString(transform),
+    transition,
+    zIndex: isDragging ? 50 : undefined,
+    boxShadow: isDragging ? "0 4px 16px 0 rgba(0,0,0,0.10)" : undefined,
+  };
 
   const handleOpenVideo = () => {
     setShowVideo(true);
@@ -50,11 +61,16 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
 
   return (
     <div
+      ref={setNodeRef}
+      style={style}
+      {...attributes}
+      {...listeners}
       className={`
         group relative overflow-hidden transition-all duration-300 ease-out
         bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800/70
         border-b border-slate-200 dark:border-slate-700/50
         ${isHovered ? "shadow-lg shadow-slate-200/50 dark:shadow-slate-900/50" : ""}
+        ${isDragging ? "ring-2 ring-blue-400" : ""}
       `}
       onMouseEnter={() => setIsHovered(true)}
       onMouseLeave={() => setIsHovered(false)}
@@ -112,7 +128,7 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
         <div className="flex items-center gap-2 shrink-0">
           {/* Bouton shuffle */}
           <Button onClick={() => onShuffle(exercise.id, muscle)} size="small" variant="outline">
-            <Shuffle />
+            <Shuffle className="h-3.5 w-3.5" />
             <span className="hidden sm:inline">{t("workout_builder.exercise.shuffle")}</span>
           </Button>
 
@@ -124,7 +140,7 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
             onClick={() => onPick(exercise.id)}
             size="small"
           >
-            <Star />
+            <Star className="h-3.5 w-3.5" />
             <span className="hidden sm:inline">{t("workout_builder.exercise.pick")}</span>
           </Button>
 
@@ -141,14 +157,7 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
       </div>
 
       {/* Video Modal */}
-      {exercise.fullVideoUrl && (
-        <ExerciseVideoModal
-          onOpenChange={setShowVideo}
-          open={showVideo}
-          title={exerciseName || exercise.name}
-          videoUrl={exercise.fullVideoUrl}
-        />
-      )}
+      {exercise.fullVideoUrl && <ExerciseVideoModal exercise={exercise} onOpenChange={setShowVideo} open={showVideo} />}
     </div>
   );
 }

+ 67 - 20
src/features/workout-builder/ui/exercise-video-modal.tsx

@@ -1,27 +1,75 @@
 "use client";
 
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { getYouTubeEmbedUrl } from "@/shared/lib/youtube";
 import { useI18n } from "locales/client";
+import { getYouTubeEmbedUrl } from "@/shared/lib/youtube";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+import type { ExerciseWithAttributes } from "../types";
 
 interface ExerciseVideoModalProps {
   open: boolean;
   onOpenChange: (open: boolean) => void;
-  videoUrl: string;
-  title: string;
+  exercise: ExerciseWithAttributes;
 }
 
-
-export function ExerciseVideoModal({ open, onOpenChange, videoUrl, title }: ExerciseVideoModalProps) {
-  const youTubeEmbedUrl = getYouTubeEmbedUrl(videoUrl);
+export function ExerciseVideoModal({ open, onOpenChange, exercise }: ExerciseVideoModalProps) {
+  console.log("exercise:", exercise);
   const t = useI18n();
+  const locale = typeof window !== "undefined" && window.navigator.language.startsWith("fr") ? "fr" : "en";
+  const title = locale === "fr" ? exercise.name : exercise.nameEn || exercise.name;
+  const introduction = locale === "fr" ? exercise.introduction : exercise.introductionEn || exercise.introduction;
+  const description = locale === "fr" ? exercise.description : exercise.descriptionEn || exercise.description;
+  const videoUrl = exercise.fullVideoUrl;
+  const youTubeEmbedUrl = getYouTubeEmbedUrl(videoUrl ?? "");
+
+  const getAttr = (name: string) => exercise.attributes.find((a) => a.attributeName.name === name)?.attributeValue.value;
+  const type = getAttr("TYPE");
+  const primaryMuscle = getAttr("PRIMARY_MUSCLE");
+  const secondaryMuscle = getAttr("SECONDARY_MUSCLE");
+  const equipment = exercise.attributes.filter((a) => a.attributeName.name === "EQUIPMENT").map((a) => a.attributeValue.value);
+  const mechanics = getAttr("MECHANICS_TYPE");
+
+  // Couleurs pour les badges
+  const badgeColors: Record<string, string> = {
+    TYPE: "bg-violet-100 text-violet-800 dark:bg-violet-900 dark:text-violet-100",
+    PRIMARY_MUSCLE: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100",
+    SECONDARY_MUSCLE: "bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-100",
+    EQUIPMENT: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-100",
+    MECHANICS_TYPE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100",
+  };
 
   return (
     <Dialog onOpenChange={onOpenChange} open={open}>
-      <DialogContent className="max-w-xl p-0 overflow-hidden">
+      <DialogContent className="max-w-2xl p-0 max-h-[80vh]">
         <DialogHeader className="flex flex-row items-center justify-between px-4 pt-4 pb-2">
-          <DialogTitle className="text-base">{title}</DialogTitle>
+          <DialogTitle className="text-lg md:text-xl font-bold flex flex-col gap-2">
+            {title}
+            <div className="flex flex-wrap gap-2 mt-2">
+              {type && <span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeColors.TYPE}`}>{type}</span>}
+              {primaryMuscle && (
+                <span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeColors.PRIMARY_MUSCLE}`}>{primaryMuscle}</span>
+              )}
+              {secondaryMuscle && (
+                <span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeColors.SECONDARY_MUSCLE}`}>{secondaryMuscle}</span>
+              )}
+              {equipment.length > 0 &&
+                equipment.map((eq) => (
+                  <span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeColors.EQUIPMENT}`} key={eq}>
+                    {eq}
+                  </span>
+                ))}
+              {mechanics && <span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeColors.MECHANICS_TYPE}`}>{mechanics}</span>}
+            </div>
+          </DialogTitle>
         </DialogHeader>
+        {/* Introduction */}
+        {introduction && (
+          <div
+            className="px-6 pt-2 pb-2 text-slate-700 dark:text-slate-200 text-sm md:text-base prose dark:prose-invert max-w-none"
+            dangerouslySetInnerHTML={{ __html: introduction }}
+          />
+        )}
+        {/* Vidéo */}
         <div className="w-full aspect-video bg-black flex items-center justify-center">
           {videoUrl ? (
             youTubeEmbedUrl ? (
@@ -33,21 +81,20 @@ export function ExerciseVideoModal({ open, onOpenChange, videoUrl, title }: Exer
                 title={title}
               />
             ) : (
-              <video
-                autoPlay
-                className="w-full h-full object-contain bg-black"
-                controls
-                poster=""
-                src={videoUrl}
-              />
+              <video autoPlay className="w-full h-full object-contain bg-black" controls poster="" src={videoUrl} />
             )
           ) : (
-            <div className="text-white text-center p-8">
-              {t("workout_builder.exercise.no_video_available")}
-            </div>
+            <div className="text-white text-center p-8">{t("workout_builder.exercise.no_video_available")}</div>
           )}
         </div>
+        {/* Instructions (description) */}
+        {description && (
+          <div
+            className="px-6 pt-4 pb-6 text-slate-700 dark:text-slate-200 text-sm md:text-base prose dark:prose-invert max-w-none border-t border-slate-100 dark:border-slate-800 mt-2"
+            dangerouslySetInnerHTML={{ __html: description }}
+          />
+        )}
       </DialogContent>
     </Dialog>
   );
-}
+}

+ 143 - 0
src/features/workout-builder/ui/exercises-selection.tsx

@@ -0,0 +1,143 @@
+import { useState, useEffect } from "react";
+import { Loader2, Plus } from "lucide-react";
+import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
+import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
+
+import { Button } from "@/components/ui/button";
+
+import { useWorkoutStepper } from "../model/use-workout-stepper";
+import { ExerciseListItem } from "./exercise-list-item";
+
+import type { ExerciseWithAttributes } from "../types";
+import type { TFunction } from "../../../../locales/client";
+
+interface ExercisesSelectionProps {
+  isLoading: boolean;
+  exercisesByMuscle: { muscle: string; exercises: ExerciseWithAttributes[] }[];
+  error: any;
+  onShuffle: (exerciseId: string, muscle: string) => void;
+  onPick: (exerciseId: string) => void;
+  onDelete: (exerciseId: string, muscle: string) => void;
+  onAdd: () => void;
+  onStartWorkout: (exercises: ExerciseWithAttributes[]) => void;
+  t: TFunction;
+}
+
+export const ExercisesSelection = ({
+  isLoading,
+  exercisesByMuscle,
+  error,
+  onShuffle,
+  onPick,
+  onDelete,
+  onAdd,
+  onStartWorkout,
+  t,
+}: ExercisesSelectionProps) => {
+  const [flatExercises, setFlatExercises] = useState<{ id: string; muscle: string; exercise: ExerciseWithAttributes }[]>([]);
+  const { setExercisesOrder } = useWorkoutStepper();
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: {
+        distance: 5,
+      },
+    }),
+  );
+
+  useEffect(() => {
+    if (exercisesByMuscle.length > 0) {
+      const flat = exercisesByMuscle.flatMap((group) =>
+        group.exercises.map((exercise) => ({
+          id: exercise.id,
+          muscle: group.muscle,
+          exercise,
+        })),
+      );
+      setFlatExercises(flat);
+    } else {
+      setFlatExercises([]);
+    }
+  }, [exercisesByMuscle]);
+
+  const handleDragEnd = (event: DragEndEvent) => {
+    const { active, over } = event;
+    if (active.id !== over?.id) {
+      setFlatExercises((items) => {
+        const oldIndex = items.findIndex((item) => item.id === active.id);
+        const newIndex = items.findIndex((item) => item.id === over?.id);
+        const newOrder = arrayMove(items, oldIndex, newIndex);
+        setExercisesOrder(newOrder.map((item) => item.id));
+        return newOrder;
+      });
+    }
+  };
+
+  const handleStartWorkout = () => {
+    const allExercises = flatExercises.map((item) => item.exercise);
+    if (allExercises.length > 0) {
+      onStartWorkout(allExercises);
+    }
+  };
+
+  return (
+    <div className="space-y-6">
+      {isLoading ? (
+        <div className="text-center">
+          <Loader2 className="h-8 w-8 animate-spin mx-auto text-blue-600" />
+          <p className="mt-4 text-slate-600 dark:text-slate-400">{t("workout_builder.loading.exercises")}</p>
+        </div>
+      ) : flatExercises.length > 0 ? (
+        <div className="max-w-4xl mx-auto">
+          {/* Liste des exercices drag and drop */}
+          <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) => (
+                  <ExerciseListItem
+                    exercise={item.exercise}
+                    isPicked={true}
+                    key={item.id}
+                    muscle={item.muscle}
+                    onDelete={onDelete}
+                    onPick={onPick}
+                    onShuffle={onShuffle}
+                  />
+                ))}
+                <div className="border-t border-slate-200 dark:border-slate-800">
+                  <button
+                    className="w-full flex items-center gap-3 py-4 px-4 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors"
+                    onClick={onAdd}
+                  >
+                    <div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
+                      <Plus className="h-4 w-4 text-white" />
+                    </div>
+                    <span className="font-medium">Add</span>
+                  </button>
+                </div>
+              </div>
+            </SortableContext>
+          </DndContext>
+          <div className="flex items-center justify-center gap-4 mt-8">
+            <Button
+              className="px-8 bg-blue-600 hover:bg-blue-700"
+              disabled={flatExercises.length === 0}
+              onClick={handleStartWorkout}
+              size="large"
+            >
+              Start Workout
+            </Button>
+          </div>
+        </div>
+      ) : error ? (
+        <div className="text-center py-20">
+          <p className="text-red-600 dark:text-red-400">{t("workout_builder.error.loading_exercises")}</p>
+        </div>
+      ) : (
+        <div className="text-center py-20">
+          <p className="text-slate-600 dark:text-slate-400">{t("workout_builder.no_exercises_found")}</p>
+        </div>
+      )}
+    </div>
+  );
+};

+ 92 - 0
src/features/workout-builder/ui/quit-workout-dialog.tsx

@@ -0,0 +1,92 @@
+"use client";
+
+import { AlertTriangle, Save, Trash2 } from "lucide-react";
+
+import { useI18n } from "locales/client";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+interface QuitWorkoutDialogProps {
+  isOpen: boolean;
+  onClose: VoidFunction;
+  onQuitWithSave: VoidFunction;
+  onQuitWithoutSave: VoidFunction;
+  elapsedTime: string;
+  exercisesCompleted: number;
+  totalExercises: number;
+}
+
+export function QuitWorkoutDialog({
+  isOpen,
+  onClose,
+  onQuitWithSave,
+  onQuitWithoutSave,
+  elapsedTime,
+  exercisesCompleted,
+  totalExercises,
+}: QuitWorkoutDialogProps) {
+  const t = useI18n();
+
+  return (
+    <Dialog onOpenChange={onClose} open={isOpen}>
+      <DialogContent className="max-w-md bg-slate-900/95 border-slate-700/50 backdrop-blur-md">
+        <DialogHeader className="pb-6">
+          <DialogTitle className="flex items-center gap-3 text-xl font-bold text-white">
+            <div className="flex items-center justify-center w-10 h-10 rounded-full bg-amber-500/20">
+              <AlertTriangle className="h-5 w-5 text-amber-400" />
+            </div>
+            {t("workout_builder.session.quit_workout_title")}
+          </DialogTitle>
+        </DialogHeader>
+
+        {/* Progress Summary */}
+        <div className="bg-slate-800/50 rounded-xl p-4 mb-6">
+          <div className="space-y-3">
+            <div className="flex justify-between items-center">
+              <span className="text-slate-300">{t("workout_builder.session.progress")}</span>
+              <span className="font-bold text-white">
+                {exercisesCompleted} / {totalExercises}
+              </span>
+            </div>
+            <div className="w-full bg-slate-700 rounded-full h-2">
+              <div
+                className="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full transition-all duration-300"
+                style={{ width: `${(exercisesCompleted / totalExercises) * 100}%` }}
+              />
+            </div>
+          </div>
+        </div>
+
+        {/* Warning Message */}
+        <div className="text-center mb-6">
+          <p className="text-slate-300 leading-relaxed">{t("workout_builder.session.quit_warning")}</p>
+        </div>
+
+        {/* Action Buttons */}
+        <div className="space-y-3">
+          {/* Save and Quit */}
+          <Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={onQuitWithSave} size="large">
+            <Save className="h-4 w-4 mr-2" />
+            {t("workout_builder.session.save_and_quit")}
+          </Button>
+
+          {/* Quit without saving */}
+          <Button
+            className="w-full border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500"
+            onClick={onQuitWithoutSave}
+            size="large"
+            variant="outline"
+          >
+            <Trash2 className="h-4 w-4 mr-2" />
+            {t("workout_builder.session.quit_without_save")}
+          </Button>
+
+          {/* Cancel */}
+          <Button className="w-full text-slate-400 hover:text-white hover:bg-slate-800" onClick={onClose} size="large" variant="ghost">
+            {t("workout_builder.session.continue_workout")}
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 0 - 14
src/features/workout-builder/ui/stepper-header.tsx

@@ -19,11 +19,8 @@ function StepperStep({ description, isActive, isCompleted, stepNumber, title }:
         {/* Cercle */}
         <div
           className={cn("flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all duration-200 flex-shrink-0", {
-            // Completed - vert avec check
             "border-green-500 bg-green-500 text-white": isCompleted,
-            // Active - bleu
             "border-blue-500 bg-blue-500 text-white": isActive,
-            // Pending - gris
             "border-gray-300 bg-gray-100 text-gray-400 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-500": !isActive && !isCompleted,
           })}
         >
@@ -57,11 +54,8 @@ function StepperStep({ description, isActive, isCompleted, stepNumber, title }:
         {/* Cercle */}
         <div
           className={cn("flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all duration-200", {
-            // Completed - vert avec check
             "border-green-500 bg-green-500 text-white": isCompleted,
-            // Active - bleu
             "border-blue-500 bg-blue-500 text-white": isActive,
-            // Pending - gris
             "border-gray-300 bg-gray-100 text-gray-400 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-500": !isActive && !isCompleted,
           })}
         >
@@ -93,14 +87,6 @@ function StepperStep({ description, isActive, isCompleted, stepNumber, title }:
   );
 }
 
-function StepperConnector({ isCompleted }: { isCompleted: boolean }) {
-  return (
-    <div className="flex-1 flex items-center px-4">
-      <div className={cn("w-full h-1 transition-colors duration-300", isCompleted ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600")} />
-    </div>
-  );
-}
-
 export function StepperHeader({ steps }: StepperHeaderProps) {
   return (
     <div className="w-full mb-8">

+ 124 - 0
src/features/workout-builder/ui/workout-stepper-footer.tsx

@@ -0,0 +1,124 @@
+"use client";
+import { ArrowLeft, ArrowRight, CheckCircle, Zap } from "lucide-react";
+
+import { useI18n } from "locales/client";
+import { Button } from "@/components/ui/button";
+
+export function WorkoutBuilderFooter({
+  currentStep,
+  totalSteps,
+  canContinue,
+  onPrevious,
+  onNext,
+  selectedEquipment,
+  selectedMuscles,
+}: {
+  currentStep: number;
+  totalSteps: number;
+  canContinue: boolean;
+  onPrevious: VoidFunction;
+  onNext: VoidFunction;
+  selectedEquipment: any[];
+  selectedMuscles: any[];
+}) {
+  const t = useI18n();
+  const isFirstStep = currentStep === 1;
+  const isFinalStep = currentStep === totalSteps;
+
+  return (
+    <div className="w-full">
+      {/* Mobile layout - vertical stack */}
+      <div className="flex flex-col gap-4 md:hidden">
+        {/* Center stats on top for mobile */}
+        <div className="flex items-center justify-center">
+          <div className="flex items-center gap-4 bg-white dark:bg-slate-800 px-4 py-2 rounded-full dark:border-slate-700 shadow-sm">
+            {currentStep === 1 && (
+              <div className="flex items-center gap-2 text-sm">
+                <Zap className="h-4 w-4 text-emerald-500" />
+                <span className="font-medium text-slate-700 dark:text-slate-300">
+                  {t("workout_builder.stats.equipment_selected", { count: selectedEquipment.length })}
+                </span>
+              </div>
+            )}
+            {currentStep === 2 && (
+              <div className="flex items-center gap-2 text-sm">
+                <CheckCircle className="h-4 w-4 text-blue-500" />
+                <span className="font-medium text-slate-700 dark:text-slate-300">
+                  {t("workout_builder.stats.muscle_selected", { count: selectedMuscles.length })}
+                </span>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* Navigation buttons */}
+        <div className="flex items-center justify-between gap-3">
+          {/* Previous button */}
+          <Button className="flex-1" disabled={isFirstStep} onClick={onPrevious} size="default" variant="ghost">
+            <div className="flex items-center gap-2">
+              <ArrowLeft className="h-4 w-4" />
+              <span className="font-medium">{t("workout_builder.navigation.previous")}</span>
+            </div>
+          </Button>
+
+          {/* Next/Complete button */}
+          <Button
+            className="flex-1"
+            disabled={!canContinue}
+            onClick={isFinalStep ? () => console.log("Complete workout!") : onNext}
+            size="default"
+            variant="default"
+          >
+            <div className="flex items-center justify-center gap-2">
+              <span className="font-semibold">
+                {isFinalStep ? t("workout_builder.navigation.complete") : t("workout_builder.navigation.continue")}
+              </span>
+              {!isFinalStep && <ArrowRight className="h-4 w-4" />}
+              {isFinalStep && <CheckCircle className="h-4 w-4" />}
+            </div>
+          </Button>
+        </div>
+      </div>
+
+      {/* Desktop layout - horizontal */}
+      <div className="hidden md:flex items-center justify-between">
+        {/* Previous button */}
+        <Button disabled={isFirstStep} onClick={onPrevious} size="large" variant="ghost">
+          <div className="flex items-center gap-2">
+            <ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1" />
+            <span className="font-medium">{t("workout_builder.navigation.previous")}</span>
+          </div>
+        </Button>
+
+        {/* Center stats */}
+        <div className="flex items-center gap-4 bg-white dark:bg-slate-800 px-6 py-3 rounded-full dark:border-slate-700 shadow-sm">
+          {currentStep === 1 && (
+            <div className="flex items-center gap-2 text-sm">
+              <Zap className="h-4 w-4 text-emerald-500" />
+              <span className="font-medium text-slate-700 dark:text-slate-300">
+                {t("workout_builder.stats.equipment_selected", { count: selectedEquipment.length })}
+              </span>
+            </div>
+          )}
+          {currentStep === 2 && (
+            <div className="flex items-center gap-2 text-sm">
+              <CheckCircle className="h-4 w-4 text-blue-500" />
+              <span className="font-medium text-slate-700 dark:text-slate-300">
+                {t("workout_builder.stats.muscle_selected", { count: selectedMuscles.length })}
+              </span>
+            </div>
+          )}
+          {/* Next step */}
+        </div>
+        {currentStep !== 3 && (
+          <Button disabled={!canContinue} onClick={onNext} size="large" variant="default">
+            <div className="flex items-center gap-2">
+              <span className="font-medium">{t("commons.next")}</span>
+              <ArrowRight className="h-4 w-4 transition-transform group-hover:-translate-x-1" />
+            </div>
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+}

+ 116 - 199
src/features/workout-builder/ui/workout-stepper.tsx

@@ -1,146 +1,31 @@
 "use client";
 
-import { useState } from "react";
-import { ArrowLeft, ArrowRight, CheckCircle, Zap, Plus } from "lucide-react";
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import Image from "next/image";
 
 import { useI18n } from "locales/client";
+import Trophy from "@public/images/trophy.png";
+import { WorkoutSessionSets } from "@/features/workout-session/ui/workout-session-sets";
+import { WorkoutSessionHeader } from "@/features/workout-session/ui/workout-session-header";
+import { WorkoutBuilderFooter } from "@/features/workout-builder/ui/workout-stepper-footer";
 import { Button } from "@/components/ui/button";
 
 import { StepperStepProps } from "../types";
 import { useWorkoutStepper } from "../model/use-workout-stepper";
+import { useWorkoutSession } from "../../workout-session/model/use-workout-session";
 import { StepperHeader } from "./stepper-header";
 import { MuscleSelection } from "./muscle-selection";
-import { ExerciseListItem } from "./exercise-list-item";
+import { ExercisesSelection } from "./exercises-selection";
 import { EquipmentSelection } from "./equipment-selection";
 
-function NavigationFooter({
-  currentStep,
-  totalSteps,
-  canContinue,
-  onPrevious,
-  onNext,
-  selectedEquipment,
-  selectedMuscles,
-}: {
-  currentStep: number;
-  totalSteps: number;
-  canContinue: boolean;
-  onPrevious: () => void;
-  onNext: () => void;
-  selectedEquipment: any[];
-  selectedMuscles: any[];
-}) {
-  const t = useI18n();
-  const isFirstStep = currentStep === 1;
-  const isFinalStep = currentStep === totalSteps;
-
-  return (
-    <div className="w-full">
-      {/* Mobile layout - vertical stack */}
-      <div className="flex flex-col gap-4 md:hidden">
-        {/* Center stats on top for mobile */}
-        <div className="flex items-center justify-center">
-          <div className="flex items-center gap-4 bg-white dark:bg-slate-800 px-4 py-2 rounded-full dark:border-slate-700 shadow-sm">
-            {currentStep === 1 && (
-              <div className="flex items-center gap-2 text-sm">
-                <Zap className="h-4 w-4 text-emerald-500" />
-                <span className="font-medium text-slate-700 dark:text-slate-300">
-                  {t("workout_builder.stats.equipment_selected", { count: selectedEquipment.length })}
-                </span>
-              </div>
-            )}
-            {currentStep === 2 && (
-              <div className="flex items-center gap-2 text-sm">
-                <CheckCircle className="h-4 w-4 text-blue-500" />
-                <span className="font-medium text-slate-700 dark:text-slate-300">
-                  {t("workout_builder.stats.muscle_selected", { count: selectedMuscles.length })}
-                </span>
-              </div>
-            )}
-          </div>
-        </div>
-
-        {/* Navigation buttons */}
-        <div className="flex items-center justify-between gap-3">
-          {/* Previous button */}
-          <Button className="flex-1" disabled={isFirstStep} onClick={onPrevious} size="default" variant="ghost">
-            <div className="flex items-center gap-2">
-              <ArrowLeft className="h-4 w-4" />
-              <span className="font-medium">{t("workout_builder.navigation.previous")}</span>
-            </div>
-          </Button>
-
-          {/* Next/Complete button */}
-          <Button
-            className="flex-1"
-            disabled={!canContinue}
-            onClick={isFinalStep ? () => console.log("Complete workout!") : onNext}
-            size="default"
-            variant="default"
-          >
-            <div className="flex items-center justify-center gap-2">
-              <span className="font-semibold">
-                {isFinalStep ? t("workout_builder.navigation.complete") : t("workout_builder.navigation.continue")}
-              </span>
-              {!isFinalStep && <ArrowRight className="h-4 w-4" />}
-              {isFinalStep && <CheckCircle className="h-4 w-4" />}
-            </div>
-          </Button>
-        </div>
-      </div>
-
-      {/* Desktop layout - horizontal */}
-      <div className="hidden md:flex items-center justify-between">
-        {/* Previous button */}
-        <Button disabled={isFirstStep} onClick={onPrevious} size="large" variant="ghost">
-          <div className="flex items-center gap-2">
-            <ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-1" />
-            <span className="font-medium">{t("workout_builder.navigation.previous")}</span>
-          </div>
-        </Button>
-
-        {/* Center stats */}
-        <div className="flex items-center gap-4 bg-white dark:bg-slate-800 px-6 py-3 rounded-full dark:border-slate-700 shadow-sm">
-          {currentStep === 1 && (
-            <div className="flex items-center gap-2 text-sm">
-              <Zap className="h-4 w-4 text-emerald-500" />
-              <span className="font-medium text-slate-700 dark:text-slate-300">
-                {t("workout_builder.stats.equipment_selected", { count: selectedEquipment.length })}
-              </span>
-            </div>
-          )}
-          {currentStep === 2 && (
-            <div className="flex items-center gap-2 text-sm">
-              <CheckCircle className="h-4 w-4 text-blue-500" />
-              <span className="font-medium text-slate-700 dark:text-slate-300">
-                {t("workout_builder.stats.muscle_selected", { count: selectedMuscles.length })}
-              </span>
-            </div>
-          )}
-        </div>
-
-        {/* Next/Complete button */}
-        <Button
-          disabled={!canContinue}
-          onClick={isFinalStep ? () => console.log("Complete workout!") : onNext}
-          size="large"
-          variant="default"
-        >
-          <div className="relative flex items-center gap-2">
-            <span className="font-semibold">
-              {isFinalStep ? t("workout_builder.navigation.complete_workout") : t("workout_builder.navigation.continue")}
-            </span>
-            {!isFinalStep && <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />}
-            {isFinalStep && <CheckCircle className="h-4 w-4" />}
-          </div>
-        </Button>
-      </div>
-    </div>
-  );
-}
+import type { ExerciseWithAttributes } from "../types";
 
 export function WorkoutStepper() {
+  const { loadSessionFromLocal } = useWorkoutSession();
+
   const t = useI18n();
+  const router = useRouter();
   const {
     currentStep,
     selectedEquipment,
@@ -155,13 +40,50 @@ export function WorkoutStepper() {
     isLoadingExercises,
     exercisesByMuscle,
     exercisesError,
+    fetchExercises,
+    exercisesOrder,
   } = useWorkoutStepper();
 
-  // État pour les exercices sélectionnés (picked)
-  const [pickedExercises, setPickedExercises] = useState<string[]>([]);
+  useEffect(() => {
+    loadSessionFromLocal();
+  }, []);
+
+  // dnd-kit et flatExercises doivent être avant tout return/condition
+  const [flatExercises, setFlatExercises] = useState<{ id: string; muscle: string; exercise: ExerciseWithAttributes }[]>([]);
+
+  useEffect(() => {
+    if (exercisesByMuscle.length > 0) {
+      const flat = exercisesByMuscle.flatMap((group) =>
+        group.exercises.map((exercise: ExerciseWithAttributes) => ({
+          id: exercise.id,
+          muscle: group.muscle,
+          exercise,
+        })),
+      );
+      setFlatExercises(flat);
+    }
+  }, [exercisesByMuscle]);
+
+  // Fetch exercises quand on arrive à l'étape 3
+  useEffect(() => {
+    if (currentStep === 3) {
+      fetchExercises();
+    }
+  }, [currentStep, selectedEquipment, selectedMuscles]);
 
-  // Calculer si on peut continuer selon l'étape
-  const canContinue = currentStep === 1 ? canProceedToStep2 : currentStep === 2 ? canProceedToStep3 : pickedExercises.length > 0;
+  const {
+    isWorkoutActive,
+    session,
+    startWorkout,
+    currentExercise,
+    formatElapsedTime,
+    isTimerRunning,
+    toggleTimer,
+    resetTimer,
+    quitWorkout,
+  } = useWorkoutSession();
+
+  const canContinue = currentStep === 1 ? canProceedToStep2 : currentStep === 2 ? canProceedToStep3 : exercisesByMuscle.length > 0;
 
   // Actions pour les exercices
   const handleShuffleExercise = (exerciseId: string, muscle: string) => {
@@ -170,7 +92,8 @@ export function WorkoutStepper() {
   };
 
   const handlePickExercise = (exerciseId: string) => {
-    setPickedExercises((prev) => (prev.includes(exerciseId) ? prev.filter((id) => id !== exerciseId) : [...prev, exerciseId]));
+    // later
+    console.log("Pick exercise:", exerciseId);
   };
 
   const handleDeleteExercise = (exerciseId: string, muscle: string) => {
@@ -183,7 +106,54 @@ export function WorkoutStepper() {
     console.log("Add exercise");
   };
 
-  // Calculer l'état des étapes avec traductions
+  const orderedExercises = exercisesOrder.length
+    ? exercisesOrder
+        .map((id) => flatExercises.find((item) => item.id === id))
+        .filter(Boolean)
+        .map((item) => item!.exercise)
+    : flatExercises.map((item) => item.exercise);
+
+  const handleStartWorkout = () => {
+    if (orderedExercises.length > 0) {
+      startWorkout(orderedExercises, selectedEquipment, selectedMuscles);
+    }
+  };
+
+  const [showCongrats, setShowCongrats] = useState(false);
+
+  if (showCongrats && !isWorkoutActive) {
+    return (
+      <div className="flex flex-col items-center justify-center py-16">
+        <Image alt="Trophée" className="w-56 h-56" src={Trophy} />
+        <h2 className="text-2xl font-bold mb-2">Bravo, séance terminée ! 🎉</h2>
+        <p className="text-lg text-slate-600 mb-6">Tu as complété tous tes exercices.</p>
+        <Button onClick={() => router.push("/profile")}>{t("commons.go_to_profile")}</Button>
+      </div>
+    );
+  }
+  if (isWorkoutActive && session) {
+    return (
+      <div className="w-full max-w-6xl mx-auto">
+        {!showCongrats && (
+          <WorkoutSessionHeader
+            currentExerciseIndex={session.exercises.findIndex((exercise) => exercise.id === currentExercise?.id)}
+            elapsedTime={formatElapsedTime()}
+            isTimerRunning={isTimerRunning}
+            onQuitWorkout={quitWorkout}
+            onResetTimer={resetTimer}
+            onSaveAndQuit={() => {
+              // TODO: Implémenter la sauvegarde pour plus tard
+              console.log("Save workout for later");
+              quitWorkout();
+            }}
+            onToggleTimer={toggleTimer}
+          />
+        )}
+        <WorkoutSessionSets isWorkoutActive={isWorkoutActive} onCongrats={() => setShowCongrats(true)} showCongrats={showCongrats} />
+      </div>
+    );
+  }
+
   const STEPPER_STEPS: StepperStepProps[] = [
     {
       stepNumber: 1,
@@ -214,7 +184,6 @@ export function WorkoutStepper() {
     isCompleted: step.stepNumber < currentStep,
   }));
 
-  // Rendu du contenu de l'étape actuelle
   const renderStepContent = () => {
     switch (currentStep) {
       case 1:
@@ -225,66 +194,17 @@ export function WorkoutStepper() {
         return <MuscleSelection onToggleMuscle={toggleMuscle} selectedEquipment={selectedEquipment} selectedMuscles={selectedMuscles} />;
       case 3:
         return (
-          <div className="space-y-6">
-            {isLoadingExercises ? (
-              <div className="text-center py-20">
-                <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
-                <p className="mt-4 text-slate-600 dark:text-slate-400">{t("workout_builder.loading.exercises")}</p>
-              </div>
-            ) : exercisesByMuscle.length > 0 ? (
-              <div className="max-w-4xl mx-auto">
-                {/* Liste des exercices */}
-                <div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
-                  {exercisesByMuscle.map((group, groupIndex) => (
-                    <div key={group.muscle}>
-                      {group.exercises.map((exercise, exerciseIndex) => (
-                        <ExerciseListItem
-                          exercise={exercise}
-                          isPicked={pickedExercises.includes(exercise.id)}
-                          key={exercise.id}
-                          muscle={group.muscle}
-                          onDelete={handleDeleteExercise}
-                          onPick={handlePickExercise}
-                          onShuffle={handleShuffleExercise}
-                        />
-                      ))}
-                    </div>
-                  ))}
-
-                  {/* Add exercise button */}
-                  <div className="border-t border-slate-200 dark:border-slate-800">
-                    <button
-                      className="w-full flex items-center gap-3 py-4 px-4 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950 transition-colors"
-                      onClick={handleAddExercise}
-                    >
-                      <div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center">
-                        <Plus className="h-4 w-4 text-white" />
-                      </div>
-                      <span className="font-medium">Add</span>
-                    </button>
-                  </div>
-                </div>
-
-                {/* Bottom actions */}
-                <div className="flex items-center justify-center gap-4 mt-8">
-                  <Button className="px-8" size="large" variant="outline">
-                    Save for later
-                  </Button>
-                  <Button className="px-8 bg-blue-600 hover:bg-blue-700" size="large">
-                    Start Workout
-                  </Button>
-                </div>
-              </div>
-            ) : exercisesError ? (
-              <div className="text-center py-20">
-                <p className="text-red-600 dark:text-red-400">{t("workout_builder.error.loading_exercises")}</p>
-              </div>
-            ) : (
-              <div className="text-center py-20">
-                <p className="text-slate-600 dark:text-slate-400">{t("workout_builder.no_exercises_found")}</p>
-              </div>
-            )}
-          </div>
+          <ExercisesSelection
+            error={exercisesError}
+            exercisesByMuscle={exercisesByMuscle}
+            isLoading={isLoadingExercises}
+            onAdd={handleAddExercise}
+            onDelete={handleDeleteExercise}
+            onPick={handlePickExercise}
+            onShuffle={handleShuffleExercise}
+            onStartWorkout={handleStartWorkout}
+            t={t}
+          />
         );
       default:
         return null;
@@ -293,14 +213,11 @@ export function WorkoutStepper() {
 
   return (
     <div className="w-full max-w-6xl mx-auto">
-      {/* En-tête du stepper */}
       <StepperHeader steps={steps} />
 
-      {/* Contenu de l'étape actuelle */}
       <div className="min-h-[400px] mb-8">{renderStepContent()}</div>
 
-      {/* Navigation footer gamifiée */}
-      <NavigationFooter
+      <WorkoutBuilderFooter
         canContinue={canContinue}
         currentStep={currentStep}
         onNext={nextStep}

+ 7 - 0
src/features/workout-session/model/use-workout-session.ts

@@ -0,0 +1,7 @@
+"use client";
+
+import { useWorkoutSessionStore } from "./workout-session.store";
+
+export function useWorkoutSession() {
+  return useWorkoutSessionStore();
+}

+ 323 - 0
src/features/workout-session/model/workout-session.store.ts

@@ -0,0 +1,323 @@
+import { create } from "zustand";
+
+import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { WorkoutSession, WorkoutSessionExercise, WorkoutSet } from "@/features/workout-session/types/workout-set";
+import { useWorkoutBuilderStore } from "@/features/workout-builder/model/workout-builder.store";
+
+import { ExerciseWithAttributes } from "../../workout-builder/types";
+
+interface WorkoutSessionProgress {
+  exerciseId: string;
+  sets: {
+    reps: number;
+    weight?: number;
+    duration?: number;
+  }[];
+  completed: boolean;
+}
+
+interface WorkoutSessionState {
+  session: WorkoutSession | null;
+  progress: Record<string, WorkoutSessionProgress>;
+  elapsedTime: number;
+  isTimerRunning: boolean;
+  isWorkoutActive: boolean;
+  currentExerciseIndex: number;
+  currentExercise: WorkoutSessionExercise | null;
+
+  // Progression
+  exercisesCompleted: number;
+  totalExercises: number;
+  progressPercent: number;
+
+  // Actions
+  startWorkout: (exercises: ExerciseWithAttributes[], equipment: any[], muscles: any[]) => void;
+  quitWorkout: () => void;
+  completeWorkout: () => void;
+  toggleTimer: () => void;
+  resetTimer: () => void;
+  updateExerciseProgress: (exerciseId: string, progressData: Partial<WorkoutSessionProgress>) => void;
+  addSet: () => void;
+  updateSet: (exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet>) => void;
+  removeSet: (exerciseIndex: number, setIndex: number) => void;
+  finishSet: (exerciseIndex: number, setIndex: number) => void;
+  goToNextExercise: () => void;
+  goToPrevExercise: () => void;
+  goToExercise: (targetIndex: number) => void;
+  formatElapsedTime: () => string;
+  getExercisesCompleted: () => number;
+  getTotalExercises: () => number;
+  loadSessionFromLocal: () => void;
+}
+
+export const useWorkoutSessionStore = create<WorkoutSessionState>((set, get) => ({
+  session: null,
+  progress: {},
+  elapsedTime: 0,
+  isTimerRunning: false,
+  isWorkoutActive: false,
+  currentExerciseIndex: 0,
+  currentExercise: null,
+  exercisesCompleted: 0,
+  totalExercises: 0,
+  progressPercent: 0,
+
+  startWorkout: (exercises, _equipment, _muscles) => {
+    const sessionExercises: WorkoutSessionExercise[] = exercises.map((ex, idx) => ({
+      ...ex,
+      order: idx,
+      sets: [
+        {
+          id: `${ex.id}-set-1`,
+          setIndex: 0,
+          types: ["REPS", "WEIGHT"],
+          valuesInt: [],
+          valuesSec: [],
+          units: [],
+          completed: false,
+        },
+      ],
+    }));
+    const newSession: WorkoutSession = {
+      id: Date.now().toString(),
+      userId: "local",
+      startedAt: new Date().toISOString(),
+      exercises: sessionExercises,
+      status: "active",
+    };
+    workoutSessionLocal.add(newSession);
+    workoutSessionLocal.setCurrent(newSession.id);
+    set({
+      session: newSession,
+      elapsedTime: 0,
+      isTimerRunning: true,
+      isWorkoutActive: true,
+      currentExerciseIndex: 0,
+      currentExercise: sessionExercises[0],
+    });
+  },
+
+  quitWorkout: () => {
+    const { session } = get();
+    if (session) {
+      workoutSessionLocal.remove(session.id);
+    }
+    set({
+      session: null,
+      progress: {},
+      elapsedTime: 0,
+      isTimerRunning: false,
+      isWorkoutActive: false,
+      currentExerciseIndex: 0,
+      currentExercise: null,
+    });
+  },
+
+  completeWorkout: () => {
+    const { session } = get();
+
+    if (session) {
+      workoutSessionLocal.update(session.id, { status: "completed", endedAt: new Date().toISOString() });
+      set({
+        session: { ...session, status: "completed", endedAt: new Date().toISOString() },
+        progress: {},
+        elapsedTime: 0,
+        isTimerRunning: false,
+        isWorkoutActive: false,
+      });
+    }
+
+    useWorkoutBuilderStore.getState().setStep(1);
+  },
+
+  toggleTimer: () => {
+    set((state) => {
+      const newIsRunning = !state.isTimerRunning;
+      if (state.session) {
+        workoutSessionLocal.update(state.session.id, { isActive: newIsRunning });
+      }
+      return { isTimerRunning: newIsRunning };
+    });
+  },
+
+  resetTimer: () => {
+    set((state) => {
+      if (state.session) {
+        workoutSessionLocal.update(state.session.id, { duration: 0 });
+      }
+      return { elapsedTime: 0 };
+    });
+  },
+
+  updateExerciseProgress: (exerciseId, progressData) => {
+    set((state) => ({
+      progress: {
+        ...state.progress,
+        [exerciseId]: {
+          ...state.progress[exerciseId],
+          exerciseId,
+          sets: [],
+          completed: false,
+          ...progressData,
+        },
+      },
+    }));
+  },
+
+  addSet: () => {
+    const { session, currentExerciseIndex } = get();
+    if (!session) return;
+    const exIdx = currentExerciseIndex;
+    const sets = session.exercises[exIdx].sets;
+    const newSet: WorkoutSet = {
+      id: `${session.exercises[exIdx].id}-set-${sets.length + 1}`,
+      setIndex: sets.length,
+      types: ["REPS"],
+      valuesInt: [],
+      valuesSec: [],
+      units: [],
+      completed: false,
+    };
+    const updatedExercises = session.exercises.map((ex, idx) => (idx === exIdx ? { ...ex, sets: [...ex.sets, newSet] } : ex));
+    workoutSessionLocal.update(session.id, { exercises: updatedExercises });
+    set({
+      session: { ...session, exercises: updatedExercises },
+      currentExercise: { ...updatedExercises[exIdx] },
+    });
+  },
+
+  updateSet: (exerciseIndex, setIndex, data) => {
+    const { session } = get();
+    if (!session) return;
+
+    const targetExercise = session.exercises[exerciseIndex];
+    if (!targetExercise) return;
+
+    const updatedSets = targetExercise.sets.map((set, idx) => (idx === setIndex ? { ...set, ...data } : set));
+    const updatedExercises = session.exercises.map((ex, idx) => (idx === exerciseIndex ? { ...ex, sets: updatedSets } : ex));
+
+    workoutSessionLocal.update(session.id, { exercises: updatedExercises });
+
+    set({
+      session: { ...session, exercises: updatedExercises },
+      currentExercise: { ...updatedExercises[exerciseIndex] },
+    });
+
+    // handle exercisesCompleted
+  },
+
+  removeSet: (exerciseIndex, setIndex) => {
+    const { session } = get();
+    if (!session) return;
+    const targetExercise = session.exercises[exerciseIndex];
+    if (!targetExercise) return;
+    const updatedSets = targetExercise.sets.filter((_, idx) => idx !== setIndex);
+    const updatedExercises = session.exercises.map((ex, idx) => (idx === exerciseIndex ? { ...ex, sets: updatedSets } : ex));
+    workoutSessionLocal.update(session.id, { exercises: updatedExercises });
+    set({
+      session: { ...session, exercises: updatedExercises },
+      currentExercise: { ...updatedExercises[exerciseIndex] },
+    });
+  },
+
+  finishSet: (exerciseIndex, setIndex) => {
+    get().updateSet(exerciseIndex, setIndex, { completed: true });
+
+    // if has completed all sets, go to next exercise
+    const { session } = get();
+    if (!session) return;
+
+    const exercise = session.exercises[exerciseIndex];
+    if (!exercise) return;
+
+    if (exercise.sets.every((set) => set.completed)) {
+      get().goToNextExercise();
+      // update exercisesCompleted
+      const exercisesCompleted = get().exercisesCompleted;
+      set({ exercisesCompleted: exercisesCompleted + 1 });
+    }
+  },
+
+  goToNextExercise: () => {
+    const { session, currentExerciseIndex } = get();
+    if (!session) return;
+    const idx = currentExerciseIndex;
+    if (idx < session.exercises.length - 1) {
+      workoutSessionLocal.update(session.id, { currentExerciseIndex: idx + 1 });
+      set({
+        currentExerciseIndex: idx + 1,
+        currentExercise: session.exercises[idx + 1],
+      });
+    }
+  },
+
+  goToPrevExercise: () => {
+    const { session, currentExerciseIndex } = get();
+    if (!session) return;
+    const idx = currentExerciseIndex;
+    if (idx > 0) {
+      workoutSessionLocal.update(session.id, { currentExerciseIndex: idx - 1 });
+      set({
+        currentExerciseIndex: idx - 1,
+        currentExercise: session.exercises[idx - 1],
+      });
+    }
+  },
+
+  goToExercise: (targetIndex) => {
+    const { session } = get();
+    if (!session) return;
+    if (targetIndex >= 0 && targetIndex < session.exercises.length) {
+      workoutSessionLocal.update(session.id, { currentExerciseIndex: targetIndex });
+      set({
+        currentExerciseIndex: targetIndex,
+        currentExercise: session.exercises[targetIndex],
+      });
+    }
+  },
+
+  getExercisesCompleted: () => {
+    const { session } = get();
+    if (!session) return 0;
+
+    // only count exercises with at least one set
+    return session.exercises
+      .filter((exercise) => exercise.sets.length > 0)
+      .filter((exercise) => exercise.sets.every((set) => set.completed)).length;
+  },
+
+  getTotalExercises: () => {
+    const { session } = get();
+    if (!session) return 0;
+    return session.exercises.length;
+  },
+
+  formatElapsedTime: () => {
+    const { elapsedTime } = get();
+    const hours = Math.floor(elapsedTime / 3600);
+    const minutes = Math.floor((elapsedTime % 3600) / 60);
+    const secs = elapsedTime % 60;
+    if (hours > 0) {
+      return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
+    }
+    return `${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
+  },
+
+  loadSessionFromLocal: () => {
+    const currentId = workoutSessionLocal.getCurrent();
+    console.log("currentId:", currentId);
+    if (currentId) {
+      const session = workoutSessionLocal.getById(currentId);
+      if (session && session.status === "active") {
+        set({
+          session,
+          isWorkoutActive: true,
+          currentExerciseIndex: session.currentExerciseIndex ?? 0,
+          currentExercise: session.exercises[session.currentExerciseIndex ?? 0],
+          elapsedTime: 0,
+          isTimerRunning: false,
+        });
+      }
+    }
+  },
+}));

+ 17 - 0
src/features/workout-session/schema/workout-session-set.schema.ts

@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+export const workoutSessionSetSchema = z.object({
+  id: z.string(),
+  setIndex: z.number().int().min(0),
+  type: z.enum(["TIME", "WEIGHT", "REPS", "BODYWEIGHT", "NA"]),
+  types: z.array(z.enum(["TIME", "WEIGHT", "REPS", "BODYWEIGHT", "NA"])).optional(),
+  valueInt: z.number().int().optional(),
+  valuesInt: z.array(z.number().int()).optional(),
+  valueSec: z.number().int().min(0).max(59).optional(),
+  valuesSec: z.array(z.number().int().min(0).max(59)).optional(),
+  unit: z.enum(["kg", "lbs"]).optional(),
+  units: z.array(z.enum(["kg", "lbs"])).optional(),
+  completed: z.boolean(),
+});
+
+export type WorkoutSetInput = z.infer<typeof workoutSessionSetSchema>;

+ 35 - 0
src/features/workout-session/types/workout-set.ts

@@ -0,0 +1,35 @@
+import { ExerciseWithAttributes } from "@/features/workout-builder/types";
+
+export type WorkoutSetType = "TIME" | "WEIGHT" | "REPS" | "BODYWEIGHT" | "NA";
+export type WorkoutSetUnit = "kg" | "lbs";
+
+export interface WorkoutSet {
+  id: string;
+  setIndex: number;
+  types: WorkoutSetType[]; // Pour supporter plusieurs colonnes
+  valueInt?: number; // reps, weight, minutes, etc.
+  valuesInt?: number[]; // Pour supporter plusieurs colonnes
+  valueSec?: number; // seconds (if TIME)
+  valuesSec?: number[]; // Pour supporter plusieurs colonnes
+  unit?: WorkoutSetUnit;
+  units?: WorkoutSetUnit[]; // Pour supporter plusieurs colonnes
+  completed: boolean;
+}
+
+export interface WorkoutSessionExercise extends ExerciseWithAttributes {
+  id: string;
+  order: number;
+  sets: WorkoutSet[];
+}
+
+export interface WorkoutSession {
+  id: string;
+  userId: string;
+  startedAt: string;
+  endedAt?: string;
+  duration?: number;
+  exercises: WorkoutSessionExercise[];
+  status?: "active" | "completed" | "synced";
+  currentExerciseIndex?: number;
+  isActive?: boolean;
+}

+ 49 - 0
src/features/workout-session/ui/workout-session-calendar.tsx

@@ -0,0 +1,49 @@
+import Calendar from "react-github-contribution-calendar";
+import React from "react";
+
+import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+
+// À placer dans le layout _app.tsx ou équivalent pour charger le CSS
+
+export function WorkoutSessionCalendar() {
+  // Récupère toutes les séances
+  const sessions = typeof window !== "undefined" ? workoutSessionLocal.getAll() : [];
+
+  // Génère un objet { 'YYYY-MM-DD': count } pour chaque jour avec au moins un workout
+  const values: Record<string, number> = {};
+  sessions.forEach((session) => {
+    // On ne compte qu'une fois par jour, même si plusieurs séances
+    const date = session.startedAt.slice(0, 10);
+    values[date] = (values[date] || 0) + 1;
+  });
+
+  // Trouve la date la plus récente pour le paramètre 'until'
+  const until =
+    sessions.length > 0
+      ? sessions.reduce((max, s) => (s.startedAt > max ? s.startedAt : max), sessions[0].startedAt).slice(0, 10)
+      : new Date().toISOString().slice(0, 10);
+
+  // Customisation
+  const panelColors = ["#E5E7EB", "#A7F3D0", "#34D399", "#059669", "#065F46"];
+  const weekNames = ["L", "M", "M", "J", "V", "S", "D"]; // TODO: i18n
+  const monthNames = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"]; // TODO: i18n
+  const panelAttributes = { rx: 1, ry: 1, height: 10, width: 10 };
+  const weekLabelAttributes = { style: { fontSize: 10, fill: "#888" } };
+  const monthLabelAttributes = { style: { fontSize: 10, fill: "#333" } };
+
+  return (
+    <div className="my-8">
+      <h3 className="text-lg font-bold mb-2">Historique d&apos;entraînement</h3>
+      <Calendar
+        monthLabelAttributes={monthLabelAttributes}
+        monthNames={monthNames}
+        panelAttributes={panelAttributes}
+        panelColors={panelColors}
+        until={until}
+        values={values}
+        weekLabelAttributes={weekLabelAttributes}
+        weekNames={weekNames}
+      />
+    </div>
+  );
+}

+ 169 - 0
src/features/workout-session/ui/workout-session-header.tsx

@@ -0,0 +1,169 @@
+"use client";
+
+import { useState } from "react";
+import { Clock, Play, Pause, RotateCcw, X, Target } from "lucide-react";
+
+import { useI18n } from "locales/client";
+import { cn } from "@/shared/lib/utils";
+import { useWorkoutSession } from "@/features/workout-session/model/use-workout-session";
+import { Button } from "@/components/ui/button";
+
+import { QuitWorkoutDialog } from "../../workout-builder/ui/quit-workout-dialog";
+
+interface WorkoutSessionHeaderProps {
+  elapsedTime: string;
+  isTimerRunning: boolean;
+  onToggleTimer: VoidFunction;
+  onResetTimer: VoidFunction;
+  onQuitWorkout: VoidFunction;
+  onSaveAndQuit?: VoidFunction;
+  currentExerciseIndex: number;
+}
+
+export function WorkoutSessionHeader({
+  elapsedTime,
+  isTimerRunning,
+  onToggleTimer,
+  onResetTimer,
+  onQuitWorkout,
+  onSaveAndQuit,
+  currentExerciseIndex,
+}: WorkoutSessionHeaderProps) {
+  const t = useI18n();
+  const [showQuitDialog, setShowQuitDialog] = useState(false);
+
+  const { getExercisesCompleted, getTotalExercises } = useWorkoutSession();
+  const exercisesCompleted = getExercisesCompleted();
+  const totalExercises = getTotalExercises();
+
+  const handleQuitClick = () => {
+    setShowQuitDialog(true);
+  };
+
+  const handleQuitWithSave = () => {
+    onSaveAndQuit?.();
+    setShowQuitDialog(false);
+  };
+
+  const handleQuitWithoutSave = () => {
+    onQuitWorkout();
+    setShowQuitDialog(false);
+  };
+
+  return (
+    <>
+      <div className="w-full mb-8">
+        {/* Minimal header, fond blanc en clair, dégradé en dark */}
+        <div className="rounded-xl p-3 bg-slate-50">
+          {/* Top row - Status et Quit button */}
+          <div className="flex items-center justify-between mb-4">
+            <div className="flex items-center gap-2">
+              <div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
+              <span className="text-emerald-400 font-semibold text-xs uppercase tracking-wider">
+                {t("workout_builder.session.workout_in_progress")}
+              </span>
+            </div>
+
+            <Button
+              className="border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500 px-2 py-1 text-xs"
+              onClick={handleQuitClick}
+              variant="outline"
+            >
+              <X className="h-3 w-3 mr-1" />
+              {t("workout_builder.session.quit_workout")}
+            </Button>
+          </div>
+
+          {/* Main content - Cards */}
+          <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
+            {/* Card 1: Temps écoulé */}
+            <div className="bg-white dark:bg-gradient-to-br dark:from-slate-800/80 dark:to-slate-700/80 rounded-lg p-3 border border-slate-100 dark:border-slate-600/30">
+              <div className="flex items-center gap-2 mb-2">
+                <div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center">
+                  <Clock className="h-4 w-4 text-blue-400" />
+                </div>
+                <div>
+                  <h3 className="text-slate-700 dark:text-white font-semibold text-base">{t("workout_builder.session.elapsed_time")}</h3>
+                </div>
+              </div>
+
+              {/* Chrono display - Large et centré */}
+              <div className="text-center">
+                <div className="text-2xl font-mono font-bold text-slate-900 dark:text-white mb-2 tracking-wider">{elapsedTime}</div>
+
+                {/* Timer controls */}
+                <div className="flex items-center justify-center gap-2">
+                  <Button
+                    className={cn(
+                      "w-8 h-8 rounded-full p-0 text-white",
+                      isTimerRunning ? "bg-amber-500 hover:bg-amber-600" : "bg-emerald-500 hover:bg-emerald-600",
+                    )}
+                    onClick={onToggleTimer}
+                  >
+                    {isTimerRunning ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
+                  </Button>
+
+                  <Button
+                    className="w-8 h-8 rounded-full p-0 border-slate-200 text-slate-400 hover:bg-slate-100 dark:border-slate-600 hover:dark:bg-slate-700"
+                    onClick={onResetTimer}
+                    variant="outline"
+                  >
+                    <RotateCcw className="h-4 w-4" />
+                  </Button>
+                </div>
+              </div>
+            </div>
+
+            {/* Card 2: Progression */}
+            <div className="bg-white dark:bg-gradient-to-br dark:from-slate-800/80 dark:to-slate-700/80 rounded-lg p-3 border border-slate-100 dark:border-slate-600/30">
+              <div className="flex items-center gap-2 mb-2">
+                <div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
+                  <Target className="h-4 w-4 text-purple-400" />
+                </div>
+                <div>
+                  <h3 className="text-slate-700 dark:text-white font-semibold text-base">
+                    {t("workout_builder.session.exercise_progress")}
+                  </h3>
+                </div>
+              </div>
+
+              <div className="space-y-2">
+                {/* Progress display */}
+                <div className="flex items-center justify-between">
+                  <span className="text-lg font-bold text-slate-900 dark:text-white">{exercisesCompleted}</span>
+                  <span className="text-slate-400">/ {totalExercises}</span>
+                </div>
+
+                {/* Progress bar */}
+                <div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-2 overflow-hidden">
+                  <div
+                    className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-500 ease-out"
+                    style={{ width: `${((currentExerciseIndex + 1) / totalExercises) * 100}%` }}
+                  />
+                </div>
+
+                {/* Percentage */}
+                <div className="text-center">
+                  <span className="text-xs text-slate-400">
+                    {Math.round((exercisesCompleted / totalExercises) * 100)}% {t("workout_builder.session.complete")}
+                  </span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Dialog de confirmation pour quitter */}
+      <QuitWorkoutDialog
+        elapsedTime={elapsedTime}
+        exercisesCompleted={exercisesCompleted}
+        isOpen={showQuitDialog}
+        onClose={() => setShowQuitDialog(false)}
+        onQuitWithoutSave={handleQuitWithoutSave}
+        onQuitWithSave={handleQuitWithSave}
+        totalExercises={totalExercises}
+      />
+    </>
+  );
+}

+ 193 - 0
src/features/workout-session/ui/workout-session-heatmap.tsx

@@ -0,0 +1,193 @@
+import React, { useRef, useEffect, useState } from "react";
+import dayjs from "dayjs";
+
+interface Props {
+  weekNames?: string[];
+  monthNames?: string[];
+  panelColors?: string[];
+  values: { [date: string]: number };
+  until: string;
+  dateFormat?: string;
+}
+
+const DEFAULT_WEEK_NAMES = ["L", "M", "M", "J", "V", "S", "D"]; // TODO i18n
+const DEFAULT_MONTH_NAMES = ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"]; // TODO i18n
+const DEFAULT_PANEL_COLORS = ["#EEE", "#34D399", "#059669", "#065F46", "#042F2E"];
+const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
+
+const PANEL_SIZE = 18;
+const PANEL_MARGIN = 2;
+const WEEK_LABEL_WIDTH = 18;
+const MONTH_LABEL_HEIGHT = 18;
+const MIN_COLUMNS = 10;
+const MAX_COLUMNS = 53;
+
+export const WorkoutSessionHeatmap: React.FC<Props> = ({
+  weekNames = DEFAULT_WEEK_NAMES,
+  monthNames = DEFAULT_MONTH_NAMES,
+  panelColors = DEFAULT_PANEL_COLORS,
+  values,
+  until,
+  dateFormat = DEFAULT_DATE_FORMAT,
+}) => {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [columns, setColumns] = useState(MAX_COLUMNS);
+  const [hovered, setHovered] = useState<null | {
+    i: number;
+    j: number;
+    tooltip: React.ReactNode;
+    mouseX: number;
+    mouseY: number;
+  }>(null);
+
+  //   responsive: adapt the number of columns to the width
+  useEffect(() => {
+    function updateColumns() {
+      if (!containerRef.current) return;
+      const width = containerRef.current.offsetWidth;
+      const available = Math.floor((width - WEEK_LABEL_WIDTH) / (PANEL_SIZE + PANEL_MARGIN));
+      setColumns(Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, available)));
+    }
+    updateColumns();
+    const observer = new window.ResizeObserver(updateColumns);
+    if (containerRef.current) observer.observe(containerRef.current);
+    return () => observer.disconnect();
+  }, []);
+
+  //   matrix of contributions
+  function makeCalendarData(history: { [k: string]: number }, lastDay: string, columns: number) {
+    const d = dayjs(lastDay, dateFormat);
+    const lastWeekend = d.endOf("week");
+    const endDate = d.endOf("day");
+    const result: ({ value: number; month: number } | null)[][] = [];
+    for (let i = 0; i < columns; i++) {
+      result[i] = [];
+      for (let j = 0; j < 7; j++) {
+        const date = lastWeekend.subtract((columns - i - 1) * 7 + (6 - j), "day");
+        if (date <= endDate) {
+          result[i][j] = {
+            value: history[date.format(dateFormat)] || 0,
+            month: date.month(),
+          };
+        } else {
+          result[i][j] = null;
+        }
+      }
+    }
+    return result;
+  }
+
+  const contributions = makeCalendarData(values, until, columns);
+  const innerDom: React.ReactElement[] = [];
+
+  for (let i = 0; i < columns; i++) {
+    for (let j = 0; j < 7; j++) {
+      const contribution = contributions[i][j];
+      if (contribution === null) continue;
+      const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i;
+      const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * j;
+      const numOfColors = panelColors.length;
+      const color = contribution.value >= numOfColors ? panelColors[numOfColors - 1] : panelColors[contribution.value];
+      // TODO i18n
+      const d = dayjs(until, dateFormat)
+        .endOf("week")
+        .subtract((columns - i - 1) * 7 + (6 - j), "day");
+      const dateStr = d.format(dateFormat);
+      const tooltip =
+        contribution.value > 0 ? (
+          <div className="text-xs text-slate-50">
+            {dateStr} : <br />
+            {contribution.value} workout{contribution.value > 1 ? "s" : ""}
+          </div>
+        ) : (
+          <div className="text-xs text-slate-50">
+            {dateStr} : <br /> No workout
+          </div>
+        );
+      innerDom.push(
+        <rect
+          fill={color}
+          height={PANEL_SIZE}
+          key={`panel_${i}_${j}`}
+          onMouseEnter={(e) => setHovered({ i, j, tooltip, mouseX: e.clientX, mouseY: e.clientY })}
+          onMouseLeave={() => setHovered(null)}
+          onMouseMove={(e) => setHovered((prev) => prev && { ...prev, mouseX: e.clientX, mouseY: e.clientY })}
+          rx={3}
+          style={{
+            cursor: "pointer",
+            stroke: hovered && hovered.i === i && hovered.j === j ? "#059669" : "transparent",
+            strokeWidth: hovered && hovered.i === i && hovered.j === j ? 2 : 0,
+            opacity: hovered && hovered.i === i && hovered.j === j ? 0.85 : 1,
+            transition: "stroke 0.1s, opacity 0.1s",
+          }}
+          width={PANEL_SIZE}
+          x={x}
+          y={y}
+        />,
+      );
+    }
+  }
+
+  for (let i = 0; i < weekNames.length; i++) {
+    const x = WEEK_LABEL_WIDTH / 2;
+    const y = MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2;
+    innerDom.push(
+      <text alignmentBaseline="central" fill="#AAA" fontSize={10} key={`week_label_${i}`} textAnchor="middle" x={x} y={y}>
+        {weekNames[i]}
+      </text>,
+    );
+  }
+
+  let prevMonth = -1;
+  for (let i = 0; i < columns; i++) {
+    const c = contributions[i][0];
+    if (c === null) continue;
+    if (columns > 1 && i === 0 && c.month !== contributions[i + 1][0]?.month) {
+      continue;
+    }
+    if (c.month !== prevMonth) {
+      const x = WEEK_LABEL_WIDTH + (PANEL_SIZE + PANEL_MARGIN) * i + PANEL_SIZE / 2;
+      const y = MONTH_LABEL_HEIGHT / 1.5;
+      innerDom.push(
+        <text alignmentBaseline="central" fill="#AAA" fontSize={12} key={`month_label_${i}`} textAnchor="middle" x={x} y={y}>
+          {monthNames[c.month]}
+        </text>,
+      );
+    }
+    prevMonth = c.month;
+  }
+
+  const tooltipNode = hovered ? (
+    <div
+      style={{
+        position: "fixed",
+        left: hovered.mouseX + 12,
+        top: hovered.mouseY - 8,
+        pointerEvents: "none",
+        zIndex: 9999,
+        background: "rgba(33,33,33,0.97)",
+        color: "#fff",
+        padding: "6px 12px",
+        borderRadius: 6,
+        fontSize: 13,
+        boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
+        whiteSpace: "nowrap",
+        maxWidth: 220,
+      }}
+    >
+      {hovered.tooltip}
+    </div>
+  ) : null;
+
+  return (
+    <div ref={containerRef} style={{ width: "100%", position: "relative" }}>
+      <svg
+        height={MONTH_LABEL_HEIGHT + (PANEL_SIZE + PANEL_MARGIN) * 7}
+        style={{ fontFamily: "Helvetica, Arial, sans-serif", width: "100%", display: "block" }}
+      >
+        {innerDom}
+      </svg>
+      {tooltipNode}
+    </div>
+  );
+};

+ 131 - 0
src/features/workout-session/ui/workout-session-list.tsx

@@ -0,0 +1,131 @@
+import { useState } from "react";
+import { Repeat2, Trash2 } from "lucide-react";
+
+import { useCurrentLocale, useI18n } from "locales/client";
+import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { InlineTooltip } from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+
+import type { WorkoutSession } from "@/shared/lib/workout-session/types/workout-session";
+
+const BADGE_COLORS = [
+  "bg-blue-100 text-blue-700 border-blue-300",
+  "bg-green-100 text-green-700 border-green-300",
+  "bg-red-100 text-red-700 border-red-300",
+  "bg-purple-100 text-purple-700 border-purple-300",
+  "bg-orange-100 text-orange-700 border-orange-300",
+  "bg-pink-100 text-pink-700 border-pink-300",
+];
+
+export function WorkoutSessionList({ onSelect }: { onSelect: (id: string) => void }) {
+  const locale = useCurrentLocale();
+  const t = useI18n();
+
+  const [sessions, setSessions] = useState<WorkoutSession[]>(() =>
+    workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()),
+  );
+
+  const handleDelete = (id: string) => {
+    workoutSessionLocal.remove(id);
+    setSessions(workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()));
+  };
+
+  const handleRepeat = (id: string) => {
+    const sessionToCopy = sessions.find((s) => s.id === id);
+    if (!sessionToCopy) return;
+    // Deep copy des exercices et sets, reset des champs nécessaires
+    const newExercises = sessionToCopy.exercises.map((ex, idx) => ({
+      ...ex,
+      sets: ex.sets.map((set, setIdx) => ({
+        ...set,
+        id: `${ex.id}-set-${setIdx + 1}-${Date.now()}`,
+        completed: false,
+      })),
+    }));
+    const newSession: WorkoutSession = {
+      ...sessionToCopy,
+      id: `${Date.now()}`,
+      startedAt: new Date().toISOString(),
+      endedAt: undefined,
+      duration: 0,
+      status: "active",
+      currentExerciseIndex: 0,
+      isActive: true,
+      exercises: newExercises,
+    };
+    workoutSessionLocal.add(newSession);
+    workoutSessionLocal.setCurrent(newSession.id);
+    setSessions(workoutSessionLocal.getAll().sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()));
+    onSelect(newSession.id);
+  };
+
+  return (
+    <div className="space-y-4">
+      <h2 className="text-xl font-bold mt-5 mb-2">{t("workout_builder.session.history", { count: sessions.length })}</h2>
+      {sessions.length === 0 && <div className="text-slate-500">{t("workout_builder.session.no_workout_yet")}</div>}
+      <ul className="divide-y divide-slate-200">
+        {sessions.map((session) => {
+          return (
+            <li
+              className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 gap-2 sm:gap-0 hover:bg-slate-50 rounded-lg space-x-4"
+              key={session.id}
+            >
+              <div className="flex items-center flex-col">
+                <span className="font-bold text-base tabular-nums">{new Date(session.startedAt).toLocaleDateString(locale)}</span>
+                <span className="text-xs text-slate-700 tabular-nums">
+                  {t("workout_builder.session.start") || "start"}
+                  {" : "}
+                  {new Date(session.startedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}
+                </span>
+                {session.endedAt && (
+                  <span className="text-xs text-slate-500 tabular-nums">
+                    {t("workout_builder.session.end") || "end"}
+                    {" : "}
+                    {new Date(session.endedAt).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}
+                  </span>
+                )}
+              </div>
+              <div className="flex flex-wrap gap-2 flex-1">
+                {session.exercises?.map((ex, idx) => {
+                  const exerciseName = locale === "fr" ? ex.name : ex.nameEn;
+                  return (
+                    <span
+                      className={`inline-block border rounded-full px-1 text-xs font-semibold ${BADGE_COLORS[idx % BADGE_COLORS.length]}`}
+                      key={ex.id}
+                    >
+                      {exerciseName?.toUpperCase() || t("workout_builder.session.exercise")}
+                    </span>
+                  );
+                })}
+              </div>
+              <div className="flex gap-2 items-center mt-2 sm:mt-0">
+                <InlineTooltip title={t("workout_builder.session.repeat")}>
+                  <Button
+                    aria-label={t("workout_builder.session.repeat")}
+                    className="w-12 h-12"
+                    onClick={() => handleRepeat(session.id)}
+                    size="icon"
+                    variant="ghost"
+                  >
+                    <Repeat2 className="w-7 h-7 text-blue-500" />
+                  </Button>
+                </InlineTooltip>
+                <InlineTooltip title={t("workout_builder.session.delete")}>
+                  <Button
+                    aria-label={t("workout_builder.session.delete")}
+                    onClick={() => handleDelete(session.id)}
+                    size="icon"
+                    variant="ghost"
+                  >
+                    <Trash2 className="w-7 h-7 text-red-500" />
+                  </Button>
+                </InlineTooltip>
+              </div>
+            </li>
+          );
+        })}
+      </ul>
+      {/* TODO: Ajouter un bouton pour créer une nouvelle séance (redirige vers le builder sans session courante) */}
+    </div>
+  );
+}

+ 236 - 0
src/features/workout-session/ui/workout-session-set.tsx

@@ -0,0 +1,236 @@
+import { Plus, Minus, Trash2 } from "lucide-react";
+
+import { useI18n } from "locales/client";
+import { WorkoutSet, WorkoutSetType, WorkoutSetUnit } from "@/features/workout-session/types/workout-set";
+import { Button } from "@/components/ui/button";
+
+interface WorkoutSetRowProps {
+  set: WorkoutSet;
+  setIndex: number;
+  onChange: (setIndex: number, data: Partial<WorkoutSet>) => void;
+  onFinish: () => void;
+  onRemove: () => void;
+}
+
+const SET_TYPES: WorkoutSetType[] = ["REPS", "WEIGHT", "TIME", "BODYWEIGHT", "NA"];
+const UNITS: WorkoutSetUnit[] = ["kg", "lbs"];
+
+export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove }: WorkoutSetRowProps) {
+  const t = useI18n();
+  // On utilise un tableau de types pour gérer plusieurs colonnes
+  const types = set.types || [];
+  const maxColumns = 4;
+
+  // Handlers pour chaque champ
+  const handleTypeChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLSelectElement>) => {
+    const newTypes = [...types];
+    newTypes[columnIndex] = e.target.value as WorkoutSetType;
+    onChange(setIndex, { types: newTypes });
+  };
+
+  const handleValueIntChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
+    const newValuesInt = Array.isArray(set.valuesInt) ? [...set.valuesInt] : [];
+    newValuesInt[columnIndex] = e.target.value ? parseInt(e.target.value, 10) : 0;
+    onChange(setIndex, { valuesInt: newValuesInt });
+  };
+
+  const handleValueSecChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
+    const newValuesSec = Array.isArray(set.valuesSec) ? [...set.valuesSec] : [];
+    newValuesSec[columnIndex] = e.target.value ? parseInt(e.target.value, 10) : 0;
+    onChange(setIndex, { valuesSec: newValuesSec });
+  };
+
+  const handleUnitChange = (columnIndex: number) => (e: React.ChangeEvent<HTMLSelectElement>) => {
+    const newUnits = Array.isArray(set.units) ? [...set.units] : [];
+    newUnits[columnIndex] = e.target.value as WorkoutSetUnit;
+    onChange(setIndex, { units: newUnits });
+  };
+
+  const addColumn = () => {
+    if (types.length < maxColumns) {
+      const newTypes = [...types, "REPS" as WorkoutSetType];
+      onChange(setIndex, { types: newTypes });
+    }
+  };
+
+  const removeColumn = (columnIndex: number) => {
+    const newTypes = types.filter((_, idx) => idx !== columnIndex);
+    const newValuesInt = Array.isArray(set.valuesInt) ? set.valuesInt.filter((_, idx) => idx !== columnIndex) : [];
+    const newValuesSec = Array.isArray(set.valuesSec) ? set.valuesSec.filter((_, idx) => idx !== columnIndex) : [];
+    const newUnits = Array.isArray(set.units) ? set.units.filter((_, idx) => idx !== columnIndex) : [];
+
+    onChange(setIndex, {
+      types: newTypes,
+      valuesInt: newValuesInt,
+      valuesSec: newValuesSec,
+      units: newUnits,
+    });
+  };
+
+  const handleEdit = () => {
+    onChange(setIndex, { completed: false });
+  };
+
+  const renderInputForType = (type: WorkoutSetType, columnIndex: number) => {
+    const valuesInt = Array.isArray(set.valuesInt) ? set.valuesInt : [set.valueInt];
+    const valuesSec = Array.isArray(set.valuesSec) ? set.valuesSec : [set.valueSec];
+    const units = Array.isArray(set.units) ? set.units : [set.unit];
+
+    switch (type) {
+      case "TIME":
+        return (
+          <div className="flex gap-1 w-full">
+            <input
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold"
+              disabled={set.completed}
+              min={0}
+              onChange={handleValueIntChange(columnIndex)}
+              placeholder="min"
+              type="number"
+              value={valuesInt[columnIndex] ?? ""}
+            />
+            <input
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold"
+              disabled={set.completed}
+              max={59}
+              min={0}
+              onChange={handleValueSecChange(columnIndex)}
+              placeholder="sec"
+              type="number"
+              value={valuesSec[columnIndex] ?? ""}
+            />
+          </div>
+        );
+      case "WEIGHT":
+        return (
+          <div className="flex gap-1 w-full items-center">
+            <input
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm text-center font-bold"
+              disabled={set.completed}
+              min={0}
+              onChange={handleValueIntChange(columnIndex)}
+              placeholder=""
+              type="number"
+              value={valuesInt[columnIndex] ?? ""}
+            />
+            <select
+              className="border border-black rounded px-1 py-1 w-1/2 text-sm font-bold bg-white"
+              disabled={set.completed}
+              onChange={handleUnitChange(columnIndex)}
+              value={units[columnIndex] ?? "kg"}
+            >
+              <option value="kg">kg</option>
+              <option value="lbs">lbs</option>
+            </select>
+          </div>
+        );
+      case "REPS":
+        return (
+          <input
+            className="border border-black rounded px-1 py-1 w-full text-sm text-center font-bold"
+            disabled={set.completed}
+            min={0}
+            onChange={handleValueIntChange(columnIndex)}
+            placeholder=""
+            type="number"
+            value={valuesInt[columnIndex] ?? ""}
+          />
+        );
+      case "BODYWEIGHT":
+        return (
+          <input
+            className="border border-black rounded px-1 py-1 w-full text-sm text-center font-bold"
+            disabled={set.completed}
+            placeholder=""
+            readOnly
+            value="✔"
+          />
+        );
+      default:
+        return null;
+    }
+  };
+
+  return (
+    <div className="w-full py-4 flex flex-col gap-2 bg-slate-50 border border-slate-200 rounded-xl shadow-sm mb-3 relative px-2 sm:px-4">
+      <div className="flex items-center justify-between mb-2">
+        <div className="bg-blue-500 text-white text-xs font-bold px-3 py-1 rounded-full shadow">SET {setIndex + 1}</div>
+        <Button
+          aria-label="Supprimer la série"
+          className="bg-red-100 hover:bg-red-200 text-red-600 rounded-full p-1 h-8 w-8 flex items-center justify-center shadow transition"
+          disabled={set.completed}
+          onClick={onRemove}
+          type="button"
+        >
+          <Trash2 className="h-4 w-4" />
+        </Button>
+      </div>
+
+      {/* Colonnes de types, stack vertical on mobile, horizontal on md+ */}
+      <div className="flex flex-col md:flex-row gap-2 w-full">
+        {types.map((type, columnIndex) => (
+          <div className="flex flex-col w-full md:w-auto" key={columnIndex}>
+            <div className="flex items-center w-full gap-1 mb-1">
+              <select
+                className="border border-black rounded font-bold px-1 py-1 text-sm w-full bg-white min-w-0"
+                disabled={set.completed}
+                onChange={handleTypeChange(columnIndex)}
+                value={type}
+              >
+                <option value="TIME">{t("workout_builder.session.time")}</option>
+                <option value="WEIGHT">{t("workout_builder.session.weight")}</option>
+                <option value="REPS">{t("workout_builder.session.reps")}</option>
+                <option value="BODYWEIGHT">{t("workout_builder.session.bodyweight")}</option>
+              </select>
+              {types.length > 1 && (
+                <Button
+                  className="p-1 h-auto bg-red-500 hover:bg-red-600 flex-shrink-0"
+                  onClick={() => removeColumn(columnIndex)}
+                  size="small"
+                  variant="destructive"
+                >
+                  <Minus className="h-3 w-3" />
+                </Button>
+              )}
+            </div>
+            {renderInputForType(type, columnIndex)}
+          </div>
+        ))}
+      </div>
+
+      {/* Bouton pour ajouter une colonne, sous les colonnes */}
+      {types.length < maxColumns && (
+        <div className="flex w-full justify-start mt-1">
+          <Button
+            className="bg-green-500 hover:bg-green-600 text-white font-bold px-4 py-2 text-sm rounded w-full md:w-auto mt-2"
+            disabled={set.completed}
+            onClick={addColumn}
+          >
+            <Plus className="h-4 w-4" />
+            {t("workout_builder.session.add_column")}
+          </Button>
+        </div>
+      )}
+
+      {/* Finish & Edit buttons, full width on mobile */}
+      <div className="flex gap-2 w-full md:w-auto mt-2">
+        <Button
+          className="bg-blue-500 hover:bg-blue-600 text-white font-bold px-4 py-2 text-sm rounded flex-1"
+          disabled={set.completed}
+          onClick={onFinish}
+        >
+          {t("workout_builder.session.finish_set")}
+        </Button>
+        {set.completed && (
+          <Button
+            className="bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold px-4 py-2 text-sm rounded flex-1 border border-gray-300"
+            onClick={handleEdit}
+            variant="outline"
+          >
+            {t("commons.edit")}
+          </Button>
+        )}
+      </div>
+    </div>
+  );
+}

+ 221 - 0
src/features/workout-session/ui/workout-session-sets.tsx

@@ -0,0 +1,221 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Image from "next/image";
+import { Check, Hourglass, Play, ArrowRight, Trophy as TrophyIcon, Plus } from "lucide-react";
+import confetti from "canvas-confetti";
+
+import { useCurrentLocale, useI18n } from "locales/client";
+import TrophyImg from "@public/images/trophy.png";
+import { cn } from "@/shared/lib/utils";
+import { useWorkoutSession } from "@/features/workout-session/model/use-workout-session";
+import { ExerciseVideoModal } from "@/features/workout-builder/ui/exercise-video-modal";
+import { Button } from "@/components/ui/button";
+
+import { WorkoutSessionSet } from "./workout-session-set";
+
+export function WorkoutSessionSets({
+  showCongrats,
+  onCongrats,
+  isWorkoutActive,
+}: {
+  showCongrats: boolean;
+  onCongrats: VoidFunction;
+  isWorkoutActive: boolean;
+}) {
+  const t = useI18n();
+  const router = useRouter();
+  const locale = useCurrentLocale();
+  const { currentExerciseIndex, session, addSet, updateSet, removeSet, finishSet, goToNextExercise, goToExercise, completeWorkout } =
+    useWorkoutSession();
+  const exerciseDetailsMap = Object.fromEntries(session?.exercises.map((ex) => [ex.id, ex]) || []);
+  const [videoModal, setVideoModal] = useState<{ open: boolean; exerciseId?: string }>({ open: false });
+
+  // Calcul de la progression (exercices terminés / total)
+  const totalExercises = session?.exercises.length || 0;
+  const completedExercises = session?.exercises.filter((ex) => ex.sets.length > 0 && ex.sets.every((set) => set.completed)).length || 0;
+  const progressPercent = totalExercises > 0 ? Math.round((completedExercises / totalExercises) * 100) : 0;
+
+  if (showCongrats) {
+    return (
+      <div className="flex flex-col items-center justify-center py-16">
+        <Image alt={t("workout_builder.session.complete") + " trophy"} className="w-56 h-56" src={TrophyImg} />
+        <h2 className="text-2xl font-bold mb-2">{t("workout_builder.session.complete") + " ! 🎉"}</h2>
+        <p className="text-lg text-slate-600 mb-6">{t("workout_builder.session.workout_in_progress")}</p>
+        <Button onClick={() => router.push("/profile")}>{t("commons.go_to_profile")}</Button>
+      </div>
+    );
+  }
+
+  if (!session) {
+    return <div className="text-center text-slate-500 py-12">{t("workout_builder.session.no_exercise_selected")}</div>;
+  }
+
+  const handleExerciseClick = (targetIndex: number) => {
+    if (targetIndex !== currentExerciseIndex) {
+      goToExercise(targetIndex);
+    }
+  };
+
+  const renderStepIcon = (idx: number, allSetsCompleted: boolean) => {
+    if (allSetsCompleted) {
+      return <Check aria-label="Exercice terminé" className="w-4 h-4 text-white" />;
+    }
+    if (idx === currentExerciseIndex) {
+      return <Hourglass aria-label="Exercice en cours" className="w-4 h-4 text-white" />;
+    }
+
+    return null;
+  };
+
+  const renderStepBackground = (idx: number, allSetsCompleted: boolean) => {
+    if (allSetsCompleted) {
+      return "bg-green-500 border-green-500";
+    }
+    if (idx === currentExerciseIndex) {
+      return "bg-blue-500 border-blue-500";
+    }
+    return "bg-slate-200 border-slate-200";
+  };
+
+  const handleFinishSession = () => {
+    completeWorkout();
+    onCongrats();
+    confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
+  };
+
+  return (
+    <div className="w-full max-w-3xl mx-auto pb-8">
+      <ol className="relative border-l-2 ml-2 border-slate-200 dark:border-slate-700">
+        {session.exercises.map((ex, idx) => {
+          const allSetsCompleted = ex.sets.length > 0 && ex.sets.every((set) => set.completed);
+          const exerciseName = locale === "fr" ? ex.name : ex.nameEn;
+
+          const details = exerciseDetailsMap[ex.id];
+          return (
+            <li
+              className={`mb-8 ml-4 ${idx !== currentExerciseIndex ? "cursor-pointer hover:opacity-80" : ""}`}
+              key={ex.id}
+              onClick={() => handleExerciseClick(idx)}
+            >
+              {/* Cercle étape */}
+              <span
+                className={cn(
+                  "absolute -left-4 flex items-center justify-center w-8 h-8 rounded-full border-4 z-10",
+                  renderStepBackground(idx, allSetsCompleted),
+                )}
+              >
+                {renderStepIcon(idx, allSetsCompleted)}
+              </span>
+              {/* Image + nom de l'exercice */}
+              <div className="flex items-center gap-3 ml-2 hover:opacity-80">
+                {details?.fullVideoImageUrl && (
+                  <div
+                    className="relative aspect-video max-w-24 rounded-lg overflow-hidden shrink-0 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700/50 cursor-pointer"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      setVideoModal({ open: true, exerciseId: ex.id });
+                    }}
+                  >
+                    <Image
+                      alt={exerciseName || "Exercise image"}
+                      className="w-full h-full object-cover scale-[1.35]"
+                      height={48}
+                      src={details.fullVideoImageUrl}
+                      width={48}
+                    />
+                    <div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity duration-200">
+                      <Button className="bg-white/80" size="icon" variant="ghost">
+                        <Play className="h-4 w-4 text-blue-600" />
+                      </Button>
+                    </div>
+                  </div>
+                )}
+                <div
+                  className={cn(
+                    "text-xl",
+                    idx === currentExerciseIndex
+                      ? "font-bold text-blue-600"
+                      : "text-slate-700 dark:text-slate-300 transition-colors hover:text-blue-500",
+                  )}
+                >
+                  {exerciseName}
+                  {details?.introduction && (
+                    <span
+                      className="block text-xs mt-1 text-slate-500 dark:text-slate-400 underline cursor-pointer hover:text-blue-600"
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        setVideoModal({ open: true, exerciseId: ex.id });
+                      }}
+                    >
+                      Voir les instructions
+                    </span>
+                  )}
+                  {/* Fallback: description si pas d'introduction */}
+                </div>
+              </div>
+              {/* Modale vidéo */}
+              {details && details.fullVideoUrl && videoModal.open && videoModal.exerciseId === ex.id && (
+                <ExerciseVideoModal
+                  exercise={details}
+                  onOpenChange={(open) => setVideoModal({ open, exerciseId: open ? ex.id : undefined })}
+                  open={videoModal.open}
+                />
+              )}
+              {/* Si exercice courant, afficher le détail */}
+              {idx === currentExerciseIndex && (
+                <div className="bg-white dark:bg-slate-900 rounded-xl my-10">
+                  {/* Liste des sets */}
+                  <div className="space-y-10 mb-8">
+                    {ex.sets.map((set, setIdx) => (
+                      <WorkoutSessionSet
+                        key={set.id}
+                        onChange={(sIdx: number, data: Partial<typeof set>) => updateSet(idx, sIdx, data)}
+                        onFinish={() => finishSet(idx, setIdx)}
+                        onRemove={() => removeSet(idx, setIdx)}
+                        set={set}
+                        setIndex={setIdx}
+                      />
+                    ))}
+                  </div>
+                  {/* Actions bas de page */}
+                  <div className="flex flex-col md:flex-row gap-3 w-full mt-2 px-2">
+                    <Button
+                      aria-label="Ajouter une série"
+                      className="flex-1 flex items-center justify-center gap-2 bg-green-500 hover:bg-green-600 text-white font-bold py-3 rounded-xl border border-green-600 transition-all duration-200 active:scale-95 focus:ring-2 focus:ring-green-400"
+                      onClick={addSet}
+                    >
+                      <Plus className="h-5 w-5" />
+                      {t("workout_builder.session.add_set")}
+                    </Button>
+                    <Button
+                      aria-label="Exercice suivant"
+                      className="flex-1 flex items-center justify-center gap-2 bg-blue-500 hover:bg-blue-600 text-white font-bold py-3 rounded-xl border border-blue-600 transition-all duration-200 active:scale-95 focus:ring-2 focus:ring-blue-400"
+                      onClick={goToNextExercise}
+                    >
+                      <ArrowRight className="h-5 w-5" />
+                      {t("workout_builder.session.next_exercise")}
+                    </Button>
+                  </div>
+                </div>
+              )}
+            </li>
+          );
+        })}
+      </ol>
+      {isWorkoutActive && (
+        <div className="flex justify-center mt-8">
+          <Button
+            aria-label="Terminer la séance"
+            className="flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white font-bold px-8 py-3 text-lg rounded-2xl border border-green-700 transition-all duration-200 active:scale-95 focus:ring-2 focus:ring-green-400"
+            onClick={handleFinishSession}
+          >
+            <TrophyIcon className="h-6 w-6" />
+            {t("workout_builder.session.finish_session")}
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}

+ 19 - 0
src/shared/lib/network/use-network-status.ts

@@ -0,0 +1,19 @@
+// src/shared/lib/network/useNetworkStatus.ts
+import { useEffect, useState } from "react";
+
+export function useNetworkStatus() {
+  const [isOnline, setIsOnline] = useState(typeof window !== "undefined" ? navigator.onLine : true);
+
+  useEffect(() => {
+    const handleOnline = () => setIsOnline(true);
+    const handleOffline = () => setIsOnline(false);
+    window.addEventListener("online", handleOnline);
+    window.addEventListener("offline", handleOffline);
+    return () => {
+      window.removeEventListener("online", handleOnline);
+      window.removeEventListener("offline", handleOffline);
+    };
+  }, []);
+
+  return { isOnline };
+}

+ 99 - 0
src/shared/lib/storage/workout-session-storage.ts

@@ -0,0 +1,99 @@
+// src/shared/lib/storage/workout-session-storage.ts
+import { v4 as uuidv4 } from "uuid";
+
+import { WorkoutSession } from "@/features/workout-session/types/workout-set";
+
+import { fetchSessions, createSession, updateSession } from "@/features/workout-session/model/workout-session.actions";
+
+const STORAGE_KEY = "workout-sessions-v2";
+
+export type SyncStatus = "pending" | "synced" | "error";
+
+export interface LocalWorkoutSession extends WorkoutSession {
+  syncStatus?: SyncStatus;
+  updatedAt: string; // ISO string
+}
+
+function getNow() {
+  return new Date().toISOString();
+}
+
+// --- Local Storage ---
+export function getLocalSessions(): LocalWorkoutSession[] {
+  if (typeof window === "undefined") return [];
+  const raw = localStorage.getItem(STORAGE_KEY);
+  if (!raw) return [];
+  try {
+    return JSON.parse(raw) as LocalWorkoutSession[];
+  } catch {
+    return [];
+  }
+}
+
+export function saveLocalSessions(sessions: LocalWorkoutSession[]) {
+  if (typeof window === "undefined") return;
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
+}
+
+export function addLocalSession(session: WorkoutSession) {
+  const sessions = getLocalSessions();
+  const newSession: LocalWorkoutSession = {
+    ...session,
+    id: session.id || uuidv4(),
+    syncStatus: "pending",
+    updatedAt: getNow(),
+  };
+  saveLocalSessions([...sessions, newSession]);
+}
+
+export function updateLocalSession(session: LocalWorkoutSession) {
+  const sessions = getLocalSessions();
+  const idx = sessions.findIndex((s) => s.id === session.id);
+  if (idx !== -1) {
+    sessions[idx] = { ...session, updatedAt: getNow(), syncStatus: "pending" };
+    saveLocalSessions(sessions);
+  }
+}
+
+export function deleteLocalSession(sessionId: string) {
+  const sessions = getLocalSessions().filter((s) => s.id !== sessionId);
+  saveLocalSessions(sessions);
+}
+
+// --- Synchronisation ---
+export async function syncSessions(userId: string) {
+  // 1. Récupère local et distant
+  const local = getLocalSessions();
+  const remote = await fetchSessions(userId);
+
+  // 2. Fusionne (par id, updatedAt)
+  const merged: LocalWorkoutSession[] = [];
+  const allIds = Array.from(new Set([...local.map((s) => s.id), ...remote.map((s) => s.id)]));
+
+  for (const id of allIds) {
+    const localSession = local.find((s) => s.id === id);
+    const remoteSession = remote.find((s) => s.id === id);
+
+    if (localSession && remoteSession) {
+      // Conflit : on garde la plus récente
+      if (new Date(localSession.updatedAt) > new Date(remoteSession.updatedAt)) {
+        // Update remote
+        await updateSession(userId, localSession);
+        merged.push({ ...localSession, syncStatus: "synced" });
+      } else {
+        // Update local
+        merged.push({ ...remoteSession, syncStatus: "synced" });
+      }
+    } else if (localSession && !remoteSession) {
+      // Nouvelle session locale à pousser
+      await createSession(userId, localSession);
+      merged.push({ ...localSession, syncStatus: "synced" });
+    } else if (!localSession && remoteSession) {
+      // Nouvelle session distante à rapatrier
+      merged.push({ ...remoteSession, syncStatus: "synced" });
+    }
+  }
+
+  saveLocalSessions(merged);
+  return merged;
+}

+ 16 - 0
src/shared/lib/workout-session/types/workout-session.ts

@@ -0,0 +1,16 @@
+import { WorkoutSessionExercise } from "@/features/workout-session/types/workout-set";
+
+export interface WorkoutSession {
+  id: string; // local: "local-xxx", serveur: uuid
+  userId: string;
+  status?: "active" | "completed" | "synced";
+  startedAt: string;
+  endedAt?: string;
+  duration?: number;
+  exercises: WorkoutSessionExercise[];
+  currentExerciseIndex?: number;
+  isActive?: boolean;
+  serverId?: string; // Si synchronisé
+}
+
+export type WorkoutSessionStatus = WorkoutSession["status"];

+ 18 - 0
src/shared/lib/workout-session/workout-session.api.ts

@@ -0,0 +1,18 @@
+import type { WorkoutSession } from "./types/workout-session";
+
+export const workoutSessionApi = {
+  getAll: async (): Promise<WorkoutSession[]> => {
+    // TODO: fetch("/api/workout-sessions")
+    return [];
+  },
+  create: async (session: WorkoutSession): Promise<{ id: string }> => {
+    // TODO: POST /api/workout-sessions
+    return { id: "server-uuid" };
+  },
+  update: async (id: string, data: Partial<WorkoutSession>) => {
+    // TODO: PATCH /api/workout-sessions/:id
+  },
+  complete: async (id: string) => {
+    // TODO: PATCH /api/workout-sessions/:id/complete
+  },
+};

+ 58 - 0
src/shared/lib/workout-session/workout-session.local.ts

@@ -0,0 +1,58 @@
+import type { WorkoutSession } from "./types/workout-session";
+
+const STORAGE_KEY = "workoutSessions";
+const MAX_SESSIONS = 10;
+const CURRENT_SESSION_KEY = "currentWorkoutSessionId";
+
+function getAll(): WorkoutSession[] {
+  try {
+    return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
+  } catch {
+    return [];
+  }
+}
+
+function saveAll(sessions: WorkoutSession[]) {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions.slice(-MAX_SESSIONS)));
+}
+
+function getById(id: string): WorkoutSession | undefined {
+  return getAll().find((s) => s.id === id);
+}
+
+function setCurrent(id: string) {
+  localStorage.setItem(CURRENT_SESSION_KEY, id);
+}
+
+function getCurrent(): string | null {
+  return localStorage.getItem(CURRENT_SESSION_KEY);
+}
+
+export const workoutSessionLocal = {
+  getAll,
+  getActive: () => getAll().find((s) => s.status === "active"),
+  add: (session: WorkoutSession) => {
+    const sessions = getAll();
+    sessions.push(session);
+    saveAll(sessions);
+  },
+  update: (id: string, data: Partial<WorkoutSession>) => {
+    const sessions = getAll().map((s) => (s.id === id ? { ...s, ...data } : s));
+    saveAll(sessions);
+  },
+  remove: (id: string) => {
+    const sessions = getAll().filter((s) => s.id !== id);
+    saveAll(sessions);
+  },
+  markSynced: (id: string, serverId: string) => {
+    const sessions = getAll().map((s) => (s.id === id ? { ...s, status: "synced" as const, serverId } : s));
+    saveAll(sessions);
+  },
+  purgeSynced: () => {
+    const sessions = getAll().filter((s) => s.status !== "synced");
+    saveAll(sessions);
+  },
+  getById,
+  setCurrent,
+  getCurrent,
+};

+ 28 - 0
src/shared/lib/workout-session/workout-session.service.ts

@@ -0,0 +1,28 @@
+import { workoutSessionLocal } from "./workout-session.local";
+import { workoutSessionApi } from "./workout-session.api";
+
+import type { WorkoutSession } from "./types/workout-session";
+
+// À remplacer par ton vrai hook/contexte d'auth
+function isUserLoggedIn(): boolean {
+  return !!localStorage.getItem("userToken");
+}
+
+export const workoutSessionService = {
+  getAll: async (): Promise<WorkoutSession[]> => {
+    if (isUserLoggedIn()) return workoutSessionApi.getAll();
+    return workoutSessionLocal.getAll();
+  },
+  add: async (session: WorkoutSession) => {
+    if (isUserLoggedIn()) return workoutSessionApi.create(session);
+    return workoutSessionLocal.add(session);
+  },
+  update: async (id: string, data: Partial<WorkoutSession>) => {
+    if (isUserLoggedIn()) return workoutSessionApi.update(id, data);
+    return workoutSessionLocal.update(id, data);
+  },
+  complete: async (id: string) => {
+    if (isUserLoggedIn()) return workoutSessionApi.complete(id);
+    return workoutSessionLocal.update(id, { status: "completed", endedAt: new Date().toISOString() });
+  },
+};

+ 15 - 0
src/shared/lib/workout-session/workout-session.sync.ts

@@ -0,0 +1,15 @@
+import { workoutSessionLocal } from "./workout-session.local";
+import { workoutSessionApi } from "./workout-session.api";
+
+export async function syncLocalWorkoutSessions() {
+  const localSessions = workoutSessionLocal.getAll().filter((s) => s.status !== "synced");
+  for (const session of localSessions) {
+    try {
+      const { id: serverId } = await workoutSessionApi.create(session);
+      workoutSessionLocal.markSynced(session.id, serverId);
+    } catch (e) {
+      // Gérer l'erreur (toast, etc.)
+    }
+  }
+  workoutSessionLocal.purgeSynced();
+}