浏览代码

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. 10 月之前
父节点
当前提交
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. 二进制
      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
+}

二进制
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();
+}