Forráskód Böngészése

Merge pull request #24 from Snouzy/feat/search

feat/search
Mat B. 1 hónapja
szülő
commit
d820f53c94

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

@@ -29,6 +29,13 @@ export default function ProfilePage() {
   return (
     <div className="px-2 sm:px-6">
       {!session && <LocalAlert className="my-4" />}
+      {session && (
+        <div className="mt-4">
+          <div>
+            <h2 className="text-2xl font-bold">Hello, {session.user?.name} 👋</h2>
+          </div>
+        </div>
+      )}
       <div className="mt-4">
         <WorkoutSessionHeatmap until={until} values={values} />
       </div>

+ 1 - 0
locales/en.ts

@@ -223,6 +223,7 @@ export default {
       elapsed_time: "Elapsed Time",
       chronometer: "Chronometer",
       exercise_progress: "Exercise Progress",
+      total_volume: "Total Volume",
       current_exercise: "Current Exercise",
       complete: "Complete",
       active: "Active",

+ 1 - 0
locales/fr.ts

@@ -224,6 +224,7 @@ export default {
       chronometer: "Chronomètre",
       total_workout_time: "Temps total d'entraînement",
       exercise_progress: "Progression",
+      total_volume: "Volume Total",
       current_exercise: "Exercice actuel",
       complete: "Terminé",
       active: "Actif",

+ 28 - 0
src/features/workout-builder/actions/pick-exercise.action.ts

@@ -0,0 +1,28 @@
+"use server";
+
+import { z } from "zod";
+
+import { actionClient } from "@/shared/api/safe-actions";
+
+const pickExerciseSchema = z.object({
+  exerciseId: z.string(),
+});
+
+export const pickExerciseAction = actionClient.schema(pickExerciseSchema).action(async ({ parsedInput }) => {
+  try {
+    const { exerciseId } = parsedInput;
+
+    // Pour l'instant, on retourne juste l'ID de l'exercice
+    // Plus tard, on pourra ajouter de la logique pour marquer l'exercice comme "picked"
+    // dans une base de données ou un système de préférences utilisateur
+
+    return {
+      success: true,
+      exerciseId,
+      message: "Exercise picked successfully",
+    };
+  } catch (error) {
+    console.error("Error picking exercise:", error);
+    return { serverError: "Failed to pick exercise" };
+  }
+});

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

@@ -21,6 +21,7 @@ export function useWorkoutStepper() {
     exercisesOrder,
     setExercisesOrder,
     shuffleExercise,
+    pickExercise,
     isShuffling,
   } = useWorkoutBuilderStore();
 
@@ -68,5 +69,8 @@ export function useWorkoutStepper() {
 
     // Additional
     isShuffling,
+
+    // Pick
+    pickExercise,
   };
 }

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

@@ -3,6 +3,7 @@ import { ExerciseAttributeValueEnum, WorkoutSessionExercise } from "@prisma/clie
 
 import { WorkoutBuilderStep } from "../types";
 import { shuffleExerciseAction } from "../actions/shuffle-exercise.action";
+import { pickExerciseAction } from "../actions/pick-exercise.action";
 import { getExercisesAction } from "./get-exercises.action";
 
 interface WorkoutBuilderState {
@@ -27,6 +28,7 @@ interface WorkoutBuilderState {
   fetchExercises: () => Promise<void>;
   setExercisesOrder: (order: string[]) => void;
   shuffleExercise: (exerciseId: string, muscle: ExerciseAttributeValueEnum) => Promise<void>;
+  pickExercise: (exerciseId: string) => Promise<void>;
   loadFromSession: (params: {
     equipment: ExerciseAttributeValueEnum[];
     muscles: ExerciseAttributeValueEnum[];
@@ -129,6 +131,28 @@ export const useWorkoutBuilderStore = create<WorkoutBuilderState>((set, get) =>
     }
   },
 
+  pickExercise: async (exerciseId) => {
+    try {
+      const result = await pickExerciseAction({ exerciseId });
+
+      if (result?.serverError) {
+        throw new Error(result.serverError);
+      }
+
+      if (result?.data?.success) {
+        // Pour l'instant, on affiche juste un message de succès
+        // Plus tard, on pourra ajouter de la logique pour marquer visuellement l'exercice
+        console.log("Exercise picked successfully:", exerciseId);
+
+        // Optionnel: on pourrait ajouter une propriété "picked" aux exercices
+        // ou maintenir une liste des exercices "picked"
+      }
+    } catch (error) {
+      console.error("Error picking exercise:", error);
+      throw error;
+    }
+  },
+
   loadFromSession: ({ equipment, muscles, exercisesByMuscle, exercisesOrder }) => {
     set({
       selectedEquipment: equipment,

+ 34 - 8
src/features/workout-builder/ui/exercise-list-item.tsx

@@ -1,6 +1,6 @@
 import { useState } from "react";
 import Image from "next/image";
-import { Play, Shuffle, Star, Trash2, GripVertical, Loader2 } from "lucide-react";
+import { Play, Shuffle, Trash2, GripVertical, Loader2 } from "lucide-react";
 import { CSS } from "@dnd-kit/utilities";
 import { useSortable } from "@dnd-kit/sortable";
 
@@ -9,6 +9,7 @@ import { InlineTooltip } from "@/components/ui/tooltip";
 import { Button } from "@/components/ui/button";
 
 import { ExerciseVideoModal } from "./exercise-video-modal";
+import { ExercisePickModal } from "./exercise-pick-modal";
 
 import type { ExerciseWithAttributes } from "../types";
 
@@ -27,6 +28,7 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
   const locale = useCurrentLocale();
   const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
   const [showVideo, setShowVideo] = useState(false);
+  const [showPickModal, setShowPickModal] = useState(false);
 
   // dnd-kit sortable
   const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: exercise.id });
@@ -42,6 +44,18 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
     setShowVideo(true);
   };
 
+  const handleOpenPickModal = () => {
+    setShowPickModal(true);
+  };
+
+  const handleClosePickModal = () => {
+    setShowPickModal(false);
+  };
+
+  const handleConfirmPick = () => {
+    onPick(exercise.id);
+  };
+
   // Déterminer la couleur du muscle
   const getMuscleConfig = (muscle: string) => {
     const configs: Record<string, { color: string; bg: string }> = {
@@ -113,11 +127,13 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
           </InlineTooltip>
 
           {/* Nom de l'exercice avec indicateurs */}
-          <div className="flex-1 min-w-0">
-            <div className="flex items-center gap-3 mb-1">
-              <h3 className="font-semibold text-slate-900 dark:text-slate-200 truncate text-sm">{exerciseName}</h3>
+          <InlineTooltip className="tooltip tooltip-bottom z-50 max-w-[300px]" title={exerciseName || ""}>
+            <div className="flex-1 min-w-0 ">
+              <div className="flex items-center gap-3 mb-1">
+                <h3 className="font-semibold text-slate-900 dark:text-slate-200 md:truncate text-sm">{exerciseName}</h3>
+              </div>
             </div>
-          </div>
+          </InlineTooltip>
         </div>
 
         <div className="flex items-center gap-1 sm:gap-2 shrink-0">
@@ -134,14 +150,15 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
           </Button>
 
           {/* Bouton pick */}
-          <Button
+          {/* TODO: V2 */}
+          {/* <Button
             className="p-1 sm:p-2 bg-blue-50 dark:bg-blue-950/50 hover:bg-blue-100 dark:hover:bg-blue-950 text-blue-600 dark:text-blue-400 border-2 border-blue-200 dark:border-blue-800"
-            onClick={() => onPick(exercise.id)}
+            onClick={handleOpenPickModal}
             size="small"
           >
             <Star className="h-3.5 w-3.5" />
             <span className="hidden sm:inline">{t("workout_builder.exercise.pick")}</span>
-          </Button>
+          </Button> */}
 
           {/* Bouton delete */}
           <Button
@@ -157,6 +174,15 @@ export function ExerciseListItem({ exercise, muscle, onShuffle, onPick, onDelete
 
       {/* Video Modal */}
       {exercise.fullVideoUrl && <ExerciseVideoModal exercise={exercise} onOpenChange={setShowVideo} open={showVideo} />}
+
+      {/* Pick Modal */}
+      <ExercisePickModal
+        exercise={exercise}
+        isOpen={showPickModal}
+        muscle={muscle}
+        onClose={handleClosePickModal}
+        onConfirmPick={handleConfirmPick}
+      />
     </div>
   );
 }

+ 170 - 0
src/features/workout-builder/ui/exercise-pick-modal.tsx

@@ -0,0 +1,170 @@
+import { useEffect, useRef } from "react";
+import Image from "next/image";
+import { X, Play } from "lucide-react";
+
+import { useCurrentLocale, useI18n } from "locales/client";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import type { ExerciseWithAttributes } from "../types";
+
+interface ExercisePickModalProps {
+  exercise: ExerciseWithAttributes | null;
+  muscle: string;
+  isOpen: boolean;
+  onClose: () => void;
+  onConfirmPick: () => void;
+}
+
+export function ExercisePickModal({ exercise, muscle, isOpen, onClose, onConfirmPick }: ExercisePickModalProps) {
+  const t = useI18n();
+  const locale = useCurrentLocale();
+  const modalRef = useRef<HTMLDialogElement>(null);
+
+  useEffect(() => {
+    const modal = modalRef.current;
+    if (!modal) return;
+
+    if (isOpen) {
+      modal.showModal();
+    } else {
+      modal.close();
+    }
+  }, [isOpen]);
+
+  useEffect(() => {
+    const modal = modalRef.current;
+    if (!modal) return;
+
+    const handleClose = () => {
+      onClose();
+    };
+
+    modal.addEventListener("close", handleClose);
+    return () => modal.removeEventListener("close", handleClose);
+  }, [onClose]);
+
+  if (!exercise) return null;
+
+  const exerciseName = locale === "fr" ? exercise.name : exercise.nameEn;
+  const exerciseDescription = locale === "fr" ? exercise.description : exercise.descriptionEn;
+
+  // Extraire les attributs utiles
+  const equipmentAttributes =
+    exercise.attributes?.filter((attr) => attr.attributeName.name === "EQUIPMENT").map((attr) => attr.attributeValue.value) || [];
+
+  const typeAttributes =
+    exercise.attributes?.filter((attr) => attr.attributeName.name === "TYPE").map((attr) => attr.attributeValue.value) || [];
+
+  const mechanicsType = exercise.attributes?.find((attr) => attr.attributeName.name === "MECHANICS_TYPE")?.attributeValue.value;
+
+  const handleConfirm = (e: React.MouseEvent) => {
+    e.preventDefault();
+    onConfirmPick();
+    onClose();
+  };
+
+  return (
+    <dialog className="modal modal-bottom sm:modal-middle" ref={modalRef}>
+      <div className="modal-box max-w-2xl">
+        {/* Header */}
+        <div className="flex items-start justify-between mb-4">
+          <div className="flex-1">
+            <h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{exerciseName}</h3>
+            <div className="flex items-center gap-2 mt-2">
+              <Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100" variant="outline">
+                {t(`workout_builder.muscles.${muscle.toLowerCase()}` as keyof typeof t)}
+              </Badge>
+              {mechanicsType && (
+                <Badge className="text-xs" variant="outline">
+                  {mechanicsType}
+                </Badge>
+              )}
+            </div>
+          </div>
+          <form method="dialog">
+            <Button className="p-1" size="small" variant="ghost">
+              <X className="h-4 w-4" />
+            </Button>
+          </form>
+        </div>
+
+        {/* Image/Video */}
+        {exercise.fullVideoImageUrl && (
+          <div className="relative h-48 bg-gradient-to-br from-slate-200 to-slate-200 dark:from-slate-700 dark:to-slate-800 rounded-lg overflow-hidden mb-4">
+            <Image
+              alt={exerciseName || "Exercise"}
+              className="object-cover"
+              fill
+              sizes="(max-width: 768px) 100vw, 50vw"
+              src={exercise.fullVideoImageUrl}
+            />
+            {exercise.fullVideoUrl && (
+              <div className="absolute inset-0 bg-black/20 flex items-center justify-center">
+                <Button className="bg-white/90 text-slate-900" size="small" variant="secondary">
+                  <Play className="h-4 w-4 mr-2" />
+                  {t("workout_builder.exercise.watch_video")}
+                </Button>
+              </div>
+            )}
+          </div>
+        )}
+
+        {/* Description */}
+        {exerciseDescription && (
+          <div className="mb-4">
+            <h4 className="font-semibold text-sm text-slate-900 dark:text-slate-100 mb-2">Description</h4>
+            <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">{exerciseDescription}</p>
+          </div>
+        )}
+
+        {/* Attributes */}
+        <div className="space-y-3 mb-6">
+          {/* Equipment */}
+          {equipmentAttributes.length > 0 && (
+            <div>
+              <h4 className="font-semibold text-sm text-slate-900 dark:text-slate-100 mb-2">Equipment</h4>
+              <div className="flex flex-wrap gap-1">
+                {equipmentAttributes.map((equipment, index) => (
+                  <Badge className="text-xs" key={index} variant="outline">
+                    {equipment.replace("_", " ")}
+                  </Badge>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Exercise Types */}
+          {typeAttributes.length > 0 && (
+            <div>
+              <h4 className="font-semibold text-sm text-slate-900 dark:text-slate-100 mb-2">Exercise Types</h4>
+              <div className="flex flex-wrap gap-1">
+                {typeAttributes.map((type, index) => (
+                  <Badge
+                    className="text-xs bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100"
+                    key={index}
+                    variant="default"
+                  >
+                    {type}
+                  </Badge>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+
+        {/* Actions */}
+        <div className="modal-action">
+          <form className="flex gap-2" method="dialog">
+            <Button size="small" variant="outline">
+              Cancel
+            </Button>
+            <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleConfirm} size="small">
+              ⭐ Confirm Pick
+            </Button>
+          </form>
+        </div>
+      </div>
+    </dialog>
+  );
+}

+ 3 - 2
src/features/workout-builder/ui/exercise-video-modal.tsx

@@ -1,4 +1,4 @@
-import { useI18n } from "locales/client";
+import { useCurrentLocale, useI18n } from "locales/client";
 import { getYouTubeEmbedUrl } from "@/shared/lib/youtube";
 import { getAttributeValueLabel } from "@/shared/lib/attribute-value-translation";
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -12,8 +12,9 @@ interface ExerciseVideoModalProps {
 }
 
 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 locale = useCurrentLocale();
   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;

+ 17 - 21
src/features/workout-builder/ui/workout-stepper.tsx

@@ -32,20 +32,21 @@ export function WorkoutStepper() {
   const {
     currentStep,
     selectedEquipment,
+    selectedMuscles,
+    exercisesByMuscle,
+    isLoadingExercises,
+    exercisesError,
     nextStep,
     prevStep,
     toggleEquipment,
     clearEquipment,
-    selectedMuscles,
     toggleMuscle,
     canProceedToStep2,
     canProceedToStep3,
-    isLoadingExercises,
-    exercisesByMuscle,
-    exercisesError,
     fetchExercises,
     exercisesOrder,
     shuffleExercise,
+    pickExercise,
     isShuffling,
   } = useWorkoutStepper();
 
@@ -74,17 +75,8 @@ export function WorkoutStepper() {
     }
   }, [currentStep, selectedEquipment, selectedMuscles, fromSession]);
 
-  const {
-    isWorkoutActive,
-    session,
-    startWorkout,
-    currentExercise,
-    formatElapsedTime,
-    isTimerRunning,
-    toggleTimer,
-    resetTimer,
-    quitWorkout,
-  } = useWorkoutSession();
+  const { isWorkoutActive, session, startWorkout, formatElapsedTime, isTimerRunning, toggleTimer, resetTimer, quitWorkout } =
+    useWorkoutSession();
 
   const canContinue = currentStep === 1 ? canProceedToStep2 : currentStep === 2 ? canProceedToStep3 : exercisesByMuscle.length > 0;
 
@@ -99,10 +91,15 @@ export function WorkoutStepper() {
     }
   };
 
-  const handlePickExercise = (exerciseId: string) => {
-    // later
-    alert("TODO : Pick exercise");
-    console.log("Pick exercise:", exerciseId);
+  const handlePickExercise = async (exerciseId: string) => {
+    try {
+      await pickExercise(exerciseId);
+      // Optionnel: afficher un toast de succès
+      console.log("Exercise picked successfully!");
+    } catch (error) {
+      console.error("Error picking exercise:", error);
+      alert("Error picking exercise. Please try again.");
+    }
   };
 
   const handleDeleteExercise = (exerciseId: string, muscle: string) => {
@@ -111,7 +108,7 @@ export function WorkoutStepper() {
   };
 
   const handleAddExercise = () => {
-    alert("TODO : Add exercise");
+    alert("TODO : Add exercise 🥶");
     console.log("Add exercise");
   };
 
@@ -165,7 +162,6 @@ export function WorkoutStepper() {
       <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}

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

@@ -2,6 +2,7 @@ import { create } from "zustand";
 
 import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
 import { WorkoutSession } from "@/shared/lib/workout-session/types/workout-session";
+import { convertWeight, type WeightUnit } from "@/shared/lib/weight-conversion";
 import { WorkoutSessionExercise, WorkoutSet } from "@/features/workout-session/types/workout-set";
 import { useWorkoutBuilderStore } from "@/features/workout-builder/model/workout-builder.store";
 
@@ -48,6 +49,8 @@ interface WorkoutSessionState {
   formatElapsedTime: () => string;
   getExercisesCompleted: () => number;
   getTotalExercises: () => number;
+  getTotalVolume: () => number;
+  getTotalVolumeInUnit: (unit: WeightUnit) => number;
   loadSessionFromLocal: () => void;
 }
 
@@ -296,6 +299,66 @@ export const useWorkoutSessionStore = create<WorkoutSessionState>((set, get) =>
     return session.exercises.length;
   },
 
+  getTotalVolume: () => {
+    const { session } = get();
+    if (!session) return 0;
+
+    let totalVolume = 0;
+
+    session.exercises.forEach((exercise) => {
+      exercise.sets.forEach((set) => {
+        // Vérifier si le set est complété et contient REPS et WEIGHT
+        if (set.completed && set.types.includes("REPS") && set.types.includes("WEIGHT") && set.valuesInt) {
+          const repsIndex = set.types.indexOf("REPS");
+          const weightIndex = set.types.indexOf("WEIGHT");
+
+          const reps = set.valuesInt[repsIndex] || 0;
+          const weight = set.valuesInt[weightIndex] || 0;
+
+          // Convertir les livres en kg si nécessaire
+          const weightInKg =
+            set.units && set.units[weightIndex] === "lbs"
+              ? weight * 0.453592 // 1 lb = 0.453592 kg
+              : weight;
+
+          totalVolume += reps * weightInKg;
+        }
+      });
+    });
+
+    return Math.round(totalVolume);
+  },
+
+  getTotalVolumeInUnit: (unit: WeightUnit) => {
+    const { session } = get();
+    if (!session) return 0;
+
+    let totalVolume = 0;
+
+    session.exercises.forEach((exercise) => {
+      exercise.sets.forEach((set) => {
+        // Vérifier si le set est complété et contient REPS et WEIGHT
+        if (set.completed && set.types.includes("REPS") && set.types.includes("WEIGHT") && set.valuesInt) {
+          const repsIndex = set.types.indexOf("REPS");
+          const weightIndex = set.types.indexOf("WEIGHT");
+
+          const reps = set.valuesInt[repsIndex] || 0;
+          const weight = set.valuesInt[weightIndex] || 0;
+
+          // Déterminer l'unité de poids originale de la série
+          const originalUnit: WeightUnit = set.units && set.units[weightIndex] === "lbs" ? "lbs" : "kg";
+
+          // Convertir vers l'unité demandée
+          const convertedWeight = convertWeight(weight, originalUnit, unit);
+
+          totalVolume += reps * convertedWeight;
+        }
+      });
+    });
+
+    return Math.round(totalVolume * 10) / 10; // Arrondir à 1 décimale
+  },
+
   formatElapsedTime: () => {
     const { elapsedTime } = get();
     const hours = Math.floor(elapsedTime / 3600);

+ 63 - 6
src/features/workout-session/ui/workout-session-header.tsx

@@ -1,9 +1,10 @@
 "use client";
 
-import { useState } from "react";
-import { Clock, Play, Pause, RotateCcw, X, Target } from "lucide-react";
+import { useState, useEffect } from "react";
+import { Clock, Play, Pause, RotateCcw, X, Target, Weight } from "lucide-react";
 
 import { useCurrentLocale, useI18n } from "locales/client";
+import { type WeightUnit } from "@/shared/lib/weight-conversion";
 import { cn } from "@/shared/lib/utils";
 import { useWorkoutSession } from "@/features/workout-session/model/use-workout-session";
 import { Timer } from "@/components/ui/timer";
@@ -17,7 +18,6 @@ interface WorkoutSessionHeaderProps {
   onToggleTimer: VoidFunction;
   onResetTimer: VoidFunction;
   onQuitWorkout: VoidFunction;
-  currentExerciseIndex: number;
 }
 
 export function WorkoutSessionHeader({
@@ -26,15 +26,30 @@ export function WorkoutSessionHeader({
   onToggleTimer,
   onResetTimer,
   onQuitWorkout,
-  currentExerciseIndex,
 }: WorkoutSessionHeaderProps) {
   const t = useI18n();
   const [showQuitDialog, setShowQuitDialog] = useState(false);
   const [resetCount, setResetCount] = useState(0);
+  const [volumeUnit, setVolumeUnit] = useState<WeightUnit>("kg");
   const locale = useCurrentLocale();
-  const { getExercisesCompleted, getTotalExercises, session } = useWorkoutSession();
+  const { getExercisesCompleted, getTotalExercises, session, getTotalVolumeInUnit } = useWorkoutSession();
   const exercisesCompleted = getExercisesCompleted();
   const totalExercises = getTotalExercises();
+  const totalVolume = getTotalVolumeInUnit(volumeUnit);
+
+  // Load volume unit preference from localStorage
+  useEffect(() => {
+    const savedUnit = localStorage.getItem("volumeUnit") as WeightUnit;
+    if (savedUnit === "kg" || savedUnit === "lbs") {
+      setVolumeUnit(savedUnit);
+    }
+  }, []);
+
+  // Save volume unit preference to localStorage
+  const handleVolumeUnitChange = (unit: WeightUnit) => {
+    setVolumeUnit(unit);
+    localStorage.setItem("volumeUnit", unit);
+  };
 
   const handleQuitClick = () => {
     setShowQuitDialog(true);
@@ -56,7 +71,6 @@ export function WorkoutSessionHeader({
         <div className="rounded-xl p-3 bg-slate-50 dark:bg-slate-900/80 border border-slate-200 dark:border-slate-700">
           <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-ping"></div>
               <span className="text-emerald-400 font-semibold text-xs uppercase tracking-wider">
                 {t("workout_builder.session.started_at")}{" "}
                 {new Date(session?.startedAt || "").toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })}
@@ -145,6 +159,49 @@ export function WorkoutSessionHeader({
                 </div>
               </div>
             </div>
+
+            {/* Card 3: Volume Total */}
+            <div className="bg-white dark:bg-slate-800 rounded-lg p-3 border border-slate-200 dark:border-slate-700 transition-colors duration-200 dark:text-white dark:hover:bg-slate-700">
+              <div className="flex items-center gap-2 mb-2">
+                <div className="w-8 h-8 rounded-full bg-orange-500/20 flex items-center justify-center">
+                  <Weight className="h-4 w-4 text-orange-400" />
+                </div>
+                <div>
+                  <h3 className="text-slate-700 dark:text-white font-semibold text-base">{t("workout_builder.session.total_volume")}</h3>
+                </div>
+              </div>
+
+              <div className="text-center">
+                <div className="text-2xl font-bold text-slate-900 dark:text-white mb-1">
+                  {totalVolume.toFixed(volumeUnit === "lbs" ? 1 : 0)}
+                </div>
+                <div className="flex items-center justify-center gap-1">
+                  <button
+                    className={cn(
+                      "text-xs px-2 py-1 rounded transition-colors",
+                      volumeUnit === "kg"
+                        ? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-100"
+                        : "text-slate-400 hover:text-slate-600 dark:hover:text-slate-300",
+                    )}
+                    onClick={() => handleVolumeUnitChange("kg")}
+                  >
+                    kg
+                  </button>
+                  <span className="text-slate-300 dark:text-slate-600">|</span>
+                  <button
+                    className={cn(
+                      "text-xs px-2 py-1 rounded transition-colors",
+                      volumeUnit === "lbs"
+                        ? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-100"
+                        : "text-slate-400 hover:text-slate-600 dark:hover:text-slate-300",
+                    )}
+                    onClick={() => handleVolumeUnitChange("lbs")}
+                  >
+                    lbs
+                  </button>
+                </div>
+              </div>
+            </div>
           </div>
         </div>
       </div>

+ 1 - 1
src/features/workout-session/ui/workout-session-list.tsx

@@ -98,7 +98,7 @@ export function WorkoutSessionList() {
   };
 
   return (
-    <div className="space-y-4 px-2 sm:px-6">
+    <div className="space-y-4 mt-10">
       <h2 className="text-xl font-bold mt-5 mb-2 text-slate-900 dark:text-slate-200">
         {t("workout_builder.session.history", { count: sessions.length })}
       </h2>

+ 7 - 7
src/features/workout-session/ui/workout-session-set.tsx

@@ -76,7 +76,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
         return (
           <div className="flex gap-1 w-full">
             <input
-              className="border border-black rounded px-1 py-2 w-1/2 text-base sm:text-sm text-center font-bold dark:bg-slate-800 dark:placeholder:text-slate-500"
+              className="border border-black rounded px-1 py-2 w-1/2 text-base text-center font-bold dark:bg-slate-800 dark:placeholder:text-slate-500"
               disabled={set.completed}
               min={0}
               onChange={handleValueIntChange(columnIndex)}
@@ -86,7 +86,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
               value={valuesInt[columnIndex] ?? ""}
             />
             <input
-              className="border border-black rounded px-1 py-2 w-1/2 text-base sm:text-sm text-center font-bold dark:bg-slate-800 dark:placeholder:text-slate-500"
+              className="border border-black rounded px-1 py-2 w-1/2 text-base text-center font-bold dark:bg-slate-800 dark:placeholder:text-slate-500"
               disabled={set.completed}
               max={59}
               min={0}
@@ -102,7 +102,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
         return (
           <div className="flex gap-1 w-full items-center">
             <input
-              className="border border-black rounded px-1 py-2 w-1/2 text-base sm:text-sm text-center font-bold dark:bg-slate-800"
+              className="border border-black rounded px-1 py-2 w-1/2 text-base text-center font-bold dark:bg-slate-800"
               disabled={set.completed}
               min={0}
               onChange={handleValueIntChange(columnIndex)}
@@ -112,7 +112,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
               value={valuesInt[columnIndex] ?? ""}
             />
             <select
-              className="border border-black rounded px-1 py-2 w-1/2 text-base sm:text-sm font-bold bg-white dark:bg-slate-800 dark:text-gray-200 h-10 "
+              className="border border-black rounded px-1 py-2 w-1/2 text-base font-bold bg-white dark:bg-slate-800 dark:text-gray-200 h-10 "
               disabled={set.completed}
               onChange={handleUnitChange(columnIndex)}
               value={units[columnIndex] ?? "kg"}
@@ -125,7 +125,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
       case "REPS":
         return (
           <input
-            className="border border-black rounded px-1 py-2 w-full text-base sm:text-sm text-center font-bold dark:bg-slate-800"
+            className="border border-black rounded px-1 py-2 w-full text-base text-center font-bold dark:bg-slate-800"
             disabled={set.completed}
             min={0}
             onChange={handleValueIntChange(columnIndex)}
@@ -138,7 +138,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
       case "BODYWEIGHT":
         return (
           <input
-            className="border border-black rounded px-1 py-2 w-full text-base sm:text-sm text-center font-bold dark:bg-slate-800"
+            className="border border-black rounded px-1 py-2 w-full text-base text-center font-bold dark:bg-slate-800"
             disabled={set.completed}
             placeholder=""
             readOnly
@@ -173,7 +173,7 @@ export function WorkoutSessionSet({ set, setIndex, onChange, onFinish, onRemove
           <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 dark:border-slate-700 rounded font-bold px-1 py-2 text-base sm:text-sm w-full bg-white dark:bg-slate-800 dark:text-gray-200 min-w-0 h-10 "
+                className="border border-black dark:border-slate-700 rounded font-bold px-1 py-2 text-base w-full bg-white dark:bg-slate-800 dark:text-gray-200 min-w-0 h-10 "
                 disabled={set.completed}
                 onChange={handleTypeChange(columnIndex)}
                 value={type}

+ 59 - 0
src/shared/lib/weight-conversion.ts

@@ -0,0 +1,59 @@
+export const WEIGHT_CONVERSION = {
+  LBS_TO_KG: 0.453592,
+  KG_TO_LBS: 2.20462,
+} as const;
+
+export type WeightUnit = "kg" | "lbs";
+
+export function convertWeight(weight: number, fromUnit: WeightUnit, toUnit: WeightUnit): number {
+  if (fromUnit === toUnit) return weight;
+
+  if (fromUnit === "lbs" && toUnit === "kg") {
+    return weight * WEIGHT_CONVERSION.LBS_TO_KG;
+  }
+
+  if (fromUnit === "kg" && toUnit === "lbs") {
+    return weight * WEIGHT_CONVERSION.KG_TO_LBS;
+  }
+
+  return weight;
+}
+
+export function formatWeight(weight: number, unit: WeightUnit, decimals: number = 1): string {
+  return `${weight.toFixed(decimals)} ${unit}`;
+}
+
+export function convertVolumeToUnit(
+  exercises: Array<{
+    sets: Array<{
+      completed: boolean;
+      types: string[];
+      valuesInt?: number[];
+      units?: string[];
+    }>;
+  }>,
+  targetUnit: WeightUnit,
+): number {
+  let totalVolume = 0;
+
+  exercises.forEach((exercise) => {
+    exercise.sets.forEach((set) => {
+      if (set.completed && set.types.includes("REPS") && set.types.includes("WEIGHT") && set.valuesInt) {
+        const repsIndex = set.types.indexOf("REPS");
+        const weightIndex = set.types.indexOf("WEIGHT");
+
+        const reps = set.valuesInt[repsIndex] || 0;
+        const weight = set.valuesInt[weightIndex] || 0;
+
+        // set unit
+        const originalUnit: WeightUnit = set.units && set.units[weightIndex] === "lbs" ? "lbs" : "kg";
+
+        const convertedWeight = convertWeight(weight, originalUnit, targetUnit);
+
+        totalVolume += reps * convertedWeight;
+      }
+    });
+  });
+
+  return Math.round(totalVolume * 10) / 10; // round to 1 decimal
+}