Browse Source

feat(workout-session): implement workout session synchronization feature with local storage and server integration
fix(locales): remove unused translation keys for complete workout in English and French
refactor(import-exercises): add normalization for exercise attribute values to ensure consistency during import

Mathias 1 month ago
parent
commit
13010512e3

+ 0 - 1
locales/en.ts

@@ -168,7 +168,6 @@ export default {
       previous: "Previous",
       continue: "Continue",
       complete: "Complete",
-      complete_workout: "Complete Workout",
     },
     stats: {
       "muscle_selected#zero": "0 muscle selected",

+ 0 - 1
locales/fr.ts

@@ -168,7 +168,6 @@ export default {
       previous: "Précédent",
       continue: "Continuer",
       complete: "Terminer",
-      complete_workout: "Terminer la séance",
     },
     stats: {
       "muscle_selected#zero": "0 muscle sélectionné",

+ 10 - 1
scripts/import-exercises-with-attributes.ts

@@ -76,6 +76,15 @@ async function ensureAttributeNameExists(name: ExerciseAttributeNameEnum) {
   return attributeName;
 }
 
+function normalizeAttributeValue(value: string): ExerciseAttributeValueEnum {
+  const cleaned = value.trim().toUpperCase();
+  if (["N/A", "NA", "NONE", "NULL", ""].includes(cleaned)) return "NA";
+  if ((Object.values(ExerciseAttributeValueEnum) as string[]).includes(cleaned)) {
+    return cleaned as ExerciseAttributeValueEnum;
+  }
+  throw new Error(`Unknown attribute value: ${value}`);
+}
+
 async function ensureAttributeValueExists(attributeNameId: string, value: ExerciseAttributeValueEnum) {
   let attributeValue = await prisma.exerciseAttributeValue.findFirst({
     where: {
@@ -156,7 +165,7 @@ async function importExercisesFromCSV(filePath: string) {
               for (const attr of exercise.attributes) {
                 try {
                   const attributeName = await ensureAttributeNameExists(attr.attributeName);
-                  const attributeValue = await ensureAttributeValueExists(attributeName.id, attr.attributeValue);
+                  const attributeValue = await ensureAttributeValueExists(attributeName.id, normalizeAttributeValue(attr.attributeValue));
 
                   await prisma.exerciseAttribute.create({
                     data: {

+ 37 - 0
src/features/workout-session/actions/sync-workout-sessions.action.ts

@@ -0,0 +1,37 @@
+"use server";
+
+import { z } from "zod";
+
+import { workoutSessionStatuses } from "@/shared/lib/workout-session/types/workout-session";
+import { prisma } from "@/shared/lib/prisma";
+import { actionClient } from "@/shared/api/safe-actions";
+
+// Schéma de validation
+const syncWorkoutSessionSchema = z.object({
+  session: z.object({
+    id: z.string(),
+    userId: z.string(),
+    startedAt: z.string(),
+    endedAt: z.string().optional(),
+    exercises: z.array(z.any()), // TODO: define the schema
+    status: z.enum(workoutSessionStatuses),
+  }),
+});
+
+export const syncWorkoutSessionAction = actionClient.schema(syncWorkoutSessionSchema).action(async ({ parsedInput }) => {
+  try {
+    const { session } = parsedInput;
+
+    // Créer ou mettre à jour la session
+    const result = await prisma.workoutSession.upsert({
+      where: { id: session.id },
+      create: session,
+      update: session,
+    });
+
+    return { data: result };
+  } catch (error) {
+    console.error("Error syncing workout session:", error);
+    return { serverError: "Failed to sync workout session" };
+  }
+});

+ 108 - 0
src/features/workout-session/model/use-sync-workout-sessions.ts

@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { workoutSessionLocal } from "@/shared/lib/workout-session/workout-session.local";
+import { useSession } from "@/features/auth/lib/auth-client";
+import { brandedToast } from "@/components/ui/toast";
+
+import { syncWorkoutSessionAction } from "../actions/sync-workout-sessions.action";
+
+interface SyncState {
+  isSyncing: boolean;
+  error: Error | null;
+  lastSyncAt: Date | null;
+}
+
+const SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes
+
+export function useSyncWorkoutSessions() {
+  const { data: session, isPending: isSessionLoading } = useSession();
+
+  const [syncState, setSyncState] = useState<SyncState>({
+    isSyncing: false,
+    error: null,
+    lastSyncAt: null,
+  });
+
+  const syncSessions = async () => {
+    if (!session?.user) return;
+
+    setSyncState((prev) => ({ ...prev, isSyncing: true, error: null }));
+
+    try {
+      const localSessions = workoutSessionLocal.getAll().filter((s) => s.status === "completed");
+
+      for (const localSession of localSessions) {
+        try {
+          const result = await syncWorkoutSessionAction({
+            session: {
+              ...localSession,
+              userId: session.user.id,
+              status: "synced",
+            },
+          });
+
+          if (result && result.serverError) {
+            throw new Error(result.serverError);
+          }
+
+          if (result && result.data) {
+            const { data } = result.data;
+
+            if (data) {
+              workoutSessionLocal.markSynced(localSession.id, data.id);
+            }
+          }
+        } catch (error) {
+          console.error(`Failed to sync session ${localSession.id}:`, error);
+        }
+      }
+
+      workoutSessionLocal.purgeSynced();
+
+      setSyncState((prev) => ({
+        ...prev,
+        isSyncing: false,
+        lastSyncAt: new Date(),
+      }));
+
+      brandedToast({
+        title: "Synchronisation réussie",
+        subtitle: `${localSessions.length} sessions synchronisées`,
+      });
+    } catch (error) {
+      setSyncState((prev) => ({
+        ...prev,
+        isSyncing: false,
+        error: error as Error,
+      }));
+
+      brandedToast({
+        title: "Erreur de synchronisation",
+        subtitle: "Certaines sessions n'ont pas pu être synchronisées",
+        variant: "error",
+      });
+    }
+  };
+
+  // Sync on login
+  useEffect(() => {
+    if (!isSessionLoading && session?.user) {
+      syncSessions();
+    }
+  }, [session, isSessionLoading]);
+
+  // Periodic sync
+  useEffect(() => {
+    if (!session?.user) return;
+
+    const interval = setInterval(syncSessions, SYNC_INTERVAL);
+    return () => clearInterval(interval);
+  }, [session]);
+
+  return {
+    syncSessions,
+    ...syncState,
+  };
+}

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

@@ -10,6 +10,7 @@ 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 { useSyncWorkoutSessions } from "@/features/workout-session/model/use-sync-workout-sessions";
 import { ExerciseVideoModal } from "@/features/workout-builder/ui/exercise-video-modal";
 import { Button } from "@/components/ui/button";
 
@@ -31,6 +32,7 @@ export function WorkoutSessionSets({
     useWorkoutSession();
   const exerciseDetailsMap = Object.fromEntries(session?.exercises.map((ex) => [ex.id, ex]) || []);
   const [videoModal, setVideoModal] = useState<{ open: boolean; exerciseId?: string }>({ open: false });
+  const { syncSessions } = useSyncWorkoutSessions();
 
   if (showCongrats) {
     return (
@@ -76,6 +78,7 @@ export function WorkoutSessionSets({
 
   const handleFinishSession = () => {
     completeWorkout();
+    syncSessions();
     onCongrats();
     confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
   };

+ 4 - 3
src/shared/lib/workout-session/types/workout-session.ts

@@ -1,9 +1,12 @@
 import { WorkoutSessionExercise } from "@/features/workout-session/types/workout-set";
 
+export const workoutSessionStatuses = ["active", "completed", "synced"] as const;
+export type WorkoutSessionStatus = (typeof workoutSessionStatuses)[number];
+
 export interface WorkoutSession {
   id: string; // local: "local-xxx", server: uuid
   userId: string;
-  status?: "active" | "completed" | "synced";
+  status?: WorkoutSessionStatus;
   startedAt: string;
   endedAt?: string;
   duration?: number;
@@ -12,5 +15,3 @@ export interface WorkoutSession {
   isActive?: boolean;
   serverId?: string; // If synced
 }
-
-export type WorkoutSessionStatus = WorkoutSession["status"];

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

@@ -3,6 +3,9 @@ import { workoutSessionApi } from "./workout-session.api";
 
 import type { WorkoutSession } from "./types/workout-session";
 
+// This is an abstraction layer to handle the local storage and/or the API calls.
+// He's the orchestrator.
+
 // TODO: replace with auth context
 function isUserLoggedIn(): boolean {
   return !!localStorage.getItem("userToken");

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

@@ -5,6 +5,7 @@ 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);