// stores/workoutStore.ts import { create } from 'zustand'; import { createSelectors } from '@/utils/createSelectors'; import { generateId } from '@/utils/ids'; import type { Workout, WorkoutState, WorkoutAction, RestTimer, WorkoutSet, WorkoutSummary, WorkoutExercise, WorkoutCompletionOptions } from '@/types/workout'; import type { WorkoutTemplate, TemplateType, TemplateExerciseConfig } from '@/types/templates'; import type { BaseExercise } from '@/types/exercise'; import { openDatabaseSync } from 'expo-sqlite'; import { FavoritesService } from '@/lib/db/services/FavoritesService'; import { router } from 'expo-router'; import { useNDKStore } from '@/lib/stores/ndk'; import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; import { TemplateService } from '@/lib//db/services/TemplateService'; import { WorkoutService } from '@/lib/db/services/WorkoutService'; // Add this import import { NostrEvent } from '@/types/nostr'; import { NDKEvent } from '@nostr-dev-kit/ndk-mobile'; import { ExerciseService } from '@/lib/db/services/ExerciseService'; /** * Workout Store * * This store manages the state for active workouts including: * - Starting, pausing, and completing workouts * - Managing exercise sets and completion status * - Handling workout timing and duration tracking * - Publishing workout data to Nostr when requested * - Tracking favorite templates * * The store uses a timestamp-based approach for duration tracking, * capturing start and end times to accurately represent workout duration * even when accounting for time spent in completion flow. */ const AUTO_SAVE_INTERVAL = 30000; // 30 seconds // Define a module-level timer reference for the workout timer // This ensures it persists even when components unmount let workoutTimerInterval: NodeJS.Timeout | null = null; interface FavoriteItem { id: string; content: WorkoutTemplate; addedAt: number; } interface ExtendedWorkoutState extends WorkoutState { isActive: boolean; isMinimized: boolean; favoriteIds: string[]; // Only store IDs in memory favoritesLoaded: boolean; isPublishing: boolean; publishingStatus?: 'saving' | 'publishing-workout' | 'publishing-social' | 'error'; publishError?: string; } interface WorkoutActions { // Core Workout Flow startWorkout: (workout: Partial) => void; pauseWorkout: () => void; resumeWorkout: () => void; completeWorkout: () => void; cancelWorkout: () => void; reset: () => void; // Exercise and Set Management updateSet: (exerciseIndex: number, setIndex: number, data: Partial) => void; completeSet: (exerciseIndex: number, setIndex: number) => void; nextExercise: () => void; previousExercise: () => void; // Rest Timer startRest: (duration: number) => void; stopRest: () => void; extendRest: (additionalSeconds: number) => void; // Timer Actions tick: (elapsed: number) => void; } interface ExtendedWorkoutActions extends WorkoutActions { // Core Workout Flow from original implementation startWorkout: (workout: Partial) => void; pauseWorkout: () => void; resumeWorkout: () => void; completeWorkout: (options?: WorkoutCompletionOptions) => Promise; cancelWorkout: () => Promise; reset: () => void; publishEvent: (event: NostrEvent) => Promise; // Exercise and Set Management from original implementation updateSet: (exerciseIndex: number, setIndex: number, data: Partial) => void; completeSet: (exerciseIndex: number, setIndex: number) => void; nextExercise: () => void; previousExercise: () => void; addExercises: (exercises: BaseExercise[]) => void; // Rest Timer from original implementation startRest: (duration: number) => void; stopRest: () => void; extendRest: (additionalSeconds: number) => void; // Timer Actions from original implementation tick: (elapsed: number) => void; // New favorite management with persistence getFavorites: () => Promise; addFavorite: (template: WorkoutTemplate) => Promise; removeFavorite: (templateId: string) => Promise; checkFavoriteStatus: (templateId: string) => boolean; loadFavorites: () => Promise; // New template management startWorkoutFromTemplate: (templateId: string, templateData?: WorkoutTemplate) => Promise; // Additional workout actions endWorkout: () => Promise; clearAutoSave: () => Promise; updateWorkoutTitle: (title: string) => void; // Minimized state actions minimizeWorkout: () => void; maximizeWorkout: () => void; // Workout timer management startWorkoutTimer: () => void; stopWorkoutTimer: () => void; } const initialState: ExtendedWorkoutState = { status: 'idle', activeWorkout: null, currentExerciseIndex: 0, currentSetIndex: 0, elapsedTime: 0, restTimer: { isActive: false, duration: 0, remaining: 0 }, isActive: false, isMinimized: false, favoriteIds: [], favoritesLoaded: false, isPublishing: false, publishingStatus: undefined, publishError: undefined }; const useWorkoutStoreBase = create()((set, get) => ({ ...initialState, // Core Workflow Flow startWorkout: (workoutData: Partial = {}) => { // First stop any existing timer to avoid duplicate timers get().stopWorkoutTimer(); const workout: Workout = { id: generateId('local'), title: workoutData.title || 'Quick Workout', type: workoutData.type || 'strength', exercises: workoutData.exercises || [], // Start with empty exercises array startTime: Date.now(), isCompleted: false, created_at: Date.now(), lastUpdated: Date.now(), availability: { source: ['local'] }, ...workoutData }; set({ status: 'active', activeWorkout: workout, currentExerciseIndex: 0, elapsedTime: 0, isActive: true, isMinimized: false }); // Start the workout timer get().startWorkoutTimer(); }, pauseWorkout: () => { const { status, activeWorkout } = get(); if (status !== 'active' || !activeWorkout) return; // Stop the timer interval when pausing get().stopWorkoutTimer(); set({ status: 'paused' }); // Auto-save when pausing saveWorkout(activeWorkout); }, resumeWorkout: () => { const { status, activeWorkout } = get(); if (status !== 'paused' || !activeWorkout) return; set({ status: 'active' }); // Restart the timer when resuming get().startWorkoutTimer(); }, completeWorkout: async (options?: WorkoutCompletionOptions) => { const { activeWorkout } = get(); if (!activeWorkout) return; // Ensure workout timer is stopped get().stopWorkoutTimer(); // If no options were provided, show the completion flow if (!options) { // Navigate to the completion flow screen router.push('/(workout)/complete'); return; } // Set publishing state set({ isPublishing: true, publishingStatus: 'saving', publishError: undefined }); const completedWorkout = { ...activeWorkout, isCompleted: true, notes: options.workoutDescription || activeWorkout.notes, // Use workoutDescription for notes lastUpdated: Date.now() }; try { // Save workout locally regardless of storage option await saveWorkout(completedWorkout); // Calculate and save summary statistics const summary = calculateWorkoutSummary(completedWorkout); await saveSummary(summary); // Handle Nostr publishing if selected and user is authenticated if (options.storageType !== 'local_only') { try { const { ndk, isAuthenticated } = useNDKStore.getState(); if (ndk && isAuthenticated) { // Update publishing status set({ publishingStatus: 'publishing-workout' }); // Create appropriate Nostr event data const eventData = options.storageType === 'publish_complete' ? NostrWorkoutService.createCompleteWorkoutEvent(completedWorkout) : NostrWorkoutService.createLimitedWorkoutEvent(completedWorkout); // Use NDK to publish try { try { console.log('Starting workout event publish...'); // Create a new event const event = new NDKEvent(ndk as any); // Set the properties event.kind = eventData.kind; event.content = eventData.content; event.tags = eventData.tags || []; event.created_at = eventData.created_at; // Add timeout for signing to prevent hanging const signPromise = event.sign(); const signTimeout = new Promise((_, reject) => { setTimeout(() => reject(new Error('Signing timeout after 15 seconds')), 15000); }); try { // Race the sign operation against a timeout await Promise.race([signPromise, signTimeout]); console.log('Event signed successfully'); // Add timeout for publishing as well const publishPromise = event.publish(); const publishTimeout = new Promise((_, reject) => { setTimeout(() => reject(new Error('Publishing timeout after 15 seconds')), 15000); }); await Promise.race([publishPromise, publishTimeout]); console.log('Successfully published workout event'); // Handle social share if selected if (options.shareOnSocial && options.socialMessage) { try { // Update publishing status for social set({ publishingStatus: 'publishing-social' }); const socialEventData = NostrWorkoutService.createSocialShareEvent( event.id, options.socialMessage ); // Create an NDK event for the social share const socialEvent = new NDKEvent(ndk as any); socialEvent.kind = socialEventData.kind; socialEvent.content = socialEventData.content; socialEvent.tags = socialEventData.tags || []; socialEvent.created_at = socialEventData.created_at; // Sign with timeout const socialSignPromise = socialEvent.sign(); const socialSignTimeout = new Promise((_, reject) => { setTimeout(() => reject(new Error('Social signing timeout after 15 seconds')), 15000); }); await Promise.race([socialSignPromise, socialSignTimeout]); // Publish with timeout const socialPublishPromise = socialEvent.publish(); const socialPublishTimeout = new Promise((_, reject) => { setTimeout(() => reject(new Error('Social publishing timeout after 15 seconds')), 15000); }); await Promise.race([socialPublishPromise, socialPublishTimeout]); console.log('Successfully published social share'); } catch (error) { const socialError = error as Error; console.error('Error publishing social share:', socialError); // Update error status but still continue set({ publishError: `Error sharing to Nostr: ${socialError.message || 'Unknown error'}` }); // Continue with workout completion even if social sharing fails } } } catch (error) { const signError = error as Error; console.error('Error signing or publishing event:', signError); // Update error state set({ publishError: `Error publishing: ${signError.message || 'Unknown error'}` }); // Specific handling for timeout errors to give user better feedback if (signError.message?.includes('timeout')) { console.warn('The signing operation timed out. This may be due to an issue with the external signer.'); set({ publishError: 'The signing operation timed out. Please try again or check your signer app.' }); } // Continue with workout completion even though publishing failed } } catch (error) { const eventCreationError = error as Error; console.error('Error creating event:', eventCreationError); // Update error state set({ publishError: `Error creating event: ${eventCreationError.message || 'Unknown error'}`, publishingStatus: 'error' }); } } catch (error) { const publishError = error as Error; console.error('Error publishing to Nostr:', publishError); // Update error state set({ publishError: `Error publishing to Nostr: ${publishError.message || 'Unknown error'}`, publishingStatus: 'error' }); } } } catch (error) { const prepError = error as Error; console.error('Error preparing Nostr events:', prepError); // Continue anyway to preserve local data set({ publishError: `Error preparing events: ${prepError.message || 'Unknown error'}`, publishingStatus: 'error' }); } } // Handle template updates if needed if (completedWorkout.templateId && options.templateAction !== 'keep_original') { try { if (options.templateAction === 'update_existing') { await TemplateService.updateExistingTemplate(completedWorkout); } else if (options.templateAction === 'save_as_new' && options.newTemplateName) { await TemplateService.saveAsNewTemplate( completedWorkout, options.newTemplateName ); } } catch (error) { const templateError = error as Error; console.error('Error updating template:', templateError); // Continue anyway to preserve workout data } } // Make sure workout timer is stopped again (just to be extra safe) get().stopWorkoutTimer(); // Finally update the app state set({ status: 'completed', activeWorkout: null, // Set to null to fully clear the workout isActive: false, isMinimized: false, isPublishing: false, // Reset publishing state publishingStatus: undefined }); // Ensure we fully reset the state get().reset(); } catch (error) { const completeError = error as Error; console.error('Error completing workout:', completeError); // Update error state set({ publishError: `Error completing workout: ${completeError.message || 'Unknown error'}`, publishingStatus: 'error', isPublishing: false }); } }, publishEvent: async (event: NostrEvent) => { try { const { ndk, isAuthenticated } = useNDKStore.getState(); if (!ndk || !isAuthenticated) { throw new Error('Not authenticated or NDK not initialized'); } // Create a new NDK event const ndkEvent = new NDKEvent(ndk as any); // Copy event properties ndkEvent.kind = event.kind; ndkEvent.content = event.content; ndkEvent.tags = event.tags || []; ndkEvent.created_at = event.created_at; // Sign and publish await ndkEvent.sign(); await ndkEvent.publish(); return ndkEvent; } catch (error) { const publishError = error as Error; console.error('Failed to publish event:', publishError); throw publishError; } }, cancelWorkout: async () => { const { activeWorkout } = get(); if (!activeWorkout) return; // Ensure workout timer is stopped get().stopWorkoutTimer(); // Prepare canceled workout with proper metadata const canceledWorkout = { ...activeWorkout, isCompleted: false, endTime: Date.now(), lastUpdated: Date.now(), status: 'canceled' }; // Log the cancellation if needed console.log('Workout canceled:', canceledWorkout.id); // Save the canceled state for analytics or recovery purposes await saveWorkout(canceledWorkout); // Clear any auto-saves // This would be the place to implement storage cleanup if needed await get().clearAutoSave(); // Reset to initial state, but preserve favorites const favoriteIds = get().favoriteIds; const favoritesLoaded = get().favoritesLoaded; set({ ...initialState, favoriteIds, favoritesLoaded }); }, // Exercise and Set Management updateSet: (exerciseIndex: number, setIndex: number, data: Partial) => { const { activeWorkout } = get(); if (!activeWorkout) return; const exercises = [...activeWorkout.exercises]; const exercise = { ...exercises[exerciseIndex] }; const sets = [...exercise.sets]; const now = Date.now(); sets[setIndex] = { ...sets[setIndex], ...data, lastUpdated: now }; exercise.sets = sets; exercise.lastUpdated = now; exercises[exerciseIndex] = exercise; set({ activeWorkout: { ...activeWorkout, exercises, lastUpdated: now } }); }, completeSet: (exerciseIndex: number, setIndex: number) => { const { activeWorkout } = get(); if (!activeWorkout) return; const exercises = [...activeWorkout.exercises]; const exercise = { ...exercises[exerciseIndex] }; const sets = [...exercise.sets]; const now = Date.now(); // Toggle completion status const isCurrentlyCompleted = sets[setIndex].isCompleted; sets[setIndex] = { ...sets[setIndex], isCompleted: !isCurrentlyCompleted, completedAt: !isCurrentlyCompleted ? now : undefined, lastUpdated: now }; exercise.sets = sets; exercise.lastUpdated = now; exercises[exerciseIndex] = exercise; set({ activeWorkout: { ...activeWorkout, exercises, lastUpdated: now } }); }, nextExercise: () => { const { activeWorkout, currentExerciseIndex } = get(); if (!activeWorkout) return; const nextIndex = Math.min( currentExerciseIndex + 1, activeWorkout.exercises.length - 1 ); set({ currentExerciseIndex: nextIndex, currentSetIndex: 0 }); }, previousExercise: () => { const { currentExerciseIndex } = get(); set({ currentExerciseIndex: Math.max(currentExerciseIndex - 1, 0), currentSetIndex: 0 }); }, addExercises: (exercises: BaseExercise[]) => { const { activeWorkout } = get(); if (!activeWorkout) return; const now = Date.now(); const newExercises: WorkoutExercise[] = exercises.map(ex => ({ id: generateId('local'), title: ex.title, type: ex.type, category: ex.category, equipment: ex.equipment, tags: ex.tags || [], availability: { source: ['local'] }, created_at: now, lastUpdated: now, sets: [ { id: generateId('local'), type: 'normal', weight: 0, reps: 0, isCompleted: false } ], isCompleted: false })); set({ activeWorkout: { ...activeWorkout, exercises: [...activeWorkout.exercises, ...newExercises], lastUpdated: now } }); }, // Rest Timer startRest: (duration: number) => set({ restTimer: { isActive: true, duration, remaining: duration } }), stopRest: () => set({ restTimer: initialState.restTimer }), extendRest: (additionalSeconds: number) => { const { restTimer } = get(); if (!restTimer.isActive) return; set({ restTimer: { ...restTimer, duration: restTimer.duration + additionalSeconds, remaining: restTimer.remaining + additionalSeconds } }); }, // Timer Actions tick: (elapsed: number) => { const { status, restTimer } = get(); if (status === 'active') { set((state: ExtendedWorkoutState) => ({ elapsedTime: state.elapsedTime + elapsed })); // Update rest timer if active if (restTimer.isActive) { const remaining = Math.max(0, restTimer.remaining - elapsed/1000); if (remaining === 0) { set({ restTimer: initialState.restTimer }); } else { set({ restTimer: { ...restTimer, remaining } }); } } } }, // Workout timer management - improved for reliability startWorkoutTimer: () => { // Clear any existing timer first to prevent duplicates if (workoutTimerInterval) { clearInterval(workoutTimerInterval); workoutTimerInterval = null; } // Start a new timer that continues to run even when components unmount workoutTimerInterval = setInterval(() => { // Get fresh state reference to avoid stale closures const { status } = useWorkoutStoreBase.getState(); if (status === 'active') { useWorkoutStoreBase.getState().tick(1000); } }, 1000); console.log('Workout timer started'); }, stopWorkoutTimer: () => { if (workoutTimerInterval) { clearInterval(workoutTimerInterval); workoutTimerInterval = null; console.log('Workout timer stopped'); } }, // Template Management startWorkoutFromTemplate: async (templateId: string, templateData?: WorkoutTemplate) => { // If template data is provided directly, use it const template = templateData || await getTemplate(templateId); if (!template) return; // Convert template exercises to workout exercises const exercises: WorkoutExercise[] = template.exercises.map((templateExercise: any) => ({ id: generateId('local'), title: templateExercise.exercise.title, type: templateExercise.exercise.type, category: templateExercise.exercise.category, equipment: templateExercise.exercise.equipment, tags: templateExercise.exercise.tags || [], availability: { source: ['local'] }, created_at: Date.now(), sets: Array(templateExercise.targetSets || 3).fill(0).map(() => ({ id: generateId('local'), type: 'normal', weight: 0, reps: templateExercise.targetReps || 0, isCompleted: false })), isCompleted: false, notes: templateExercise.notes || '' })); // Start workout with template data get().startWorkout({ title: template.title, type: template.type || 'strength', exercises, templateId: template.id }); }, updateWorkoutTitle: (title: string) => { const { activeWorkout } = get(); if (!activeWorkout) return; set({ activeWorkout: { ...activeWorkout, title, lastUpdated: Date.now() } }); }, // Favorite Management with SQLite persistence - IMPROVED VERSION loadFavorites: async () => { try { // Get the favorites service through a local import trick since we can't use hooks here const db = openDatabaseSync('powr.db'); const favoritesService = new FavoritesService(db); // Load just the IDs const favoriteIds = await favoritesService.getFavoriteIds('template'); set({ favoriteIds, favoritesLoaded: true }); console.log(`Loaded ${favoriteIds.length} favorite IDs from database`); } catch (error) { console.error('Error loading favorites:', error); set({ favoritesLoaded: true }); // Mark as loaded even on error } }, getFavorites: async () => { const { favoriteIds, favoritesLoaded } = get(); // If favorites haven't been loaded from database yet, load them if (!favoritesLoaded) { await get().loadFavorites(); } // If no favorites, return empty array if (get().favoriteIds.length === 0) { return []; } try { const db = openDatabaseSync('powr.db'); const favoritesService = new FavoritesService(db); return await favoritesService.getFavorites('template'); } catch (error) { console.error('Error fetching favorites content:', error); return []; } }, addFavorite: async (template: WorkoutTemplate) => { try { const db = openDatabaseSync('powr.db'); const favoritesService = new FavoritesService(db); // Add to favorites database await favoritesService.addFavorite('template', template.id, template); // Update just the ID in memory state set(state => { // Only add if not already present if (!state.favoriteIds.includes(template.id)) { return { favoriteIds: [...state.favoriteIds, template.id] }; } return state; }); console.log(`Added template "${template.title}" to favorites`); } catch (error) { console.error('Error adding favorite:', error); throw error; } }, removeFavorite: async (templateId: string) => { try { const db = openDatabaseSync('powr.db'); const favoritesService = new FavoritesService(db); // Remove from favorites database await favoritesService.removeFavorite('template', templateId); // Update IDs in memory state set(state => ({ favoriteIds: state.favoriteIds.filter(id => id !== templateId) })); console.log(`Removed template with ID "${templateId}" from favorites`); } catch (error) { console.error('Error removing favorite:', error); throw error; } }, checkFavoriteStatus: (templateId: string) => { return get().favoriteIds.includes(templateId); }, endWorkout: async () => { const { activeWorkout } = get(); if (!activeWorkout) return; // Set the end time right when entering completion flow set({ activeWorkout: { ...activeWorkout, endTime: Date.now(), lastUpdated: Date.now() } }); // Make sure to stop the timer before navigating get().stopWorkoutTimer(); // Navigate to completion screen router.push('/(workout)/complete'); }, clearAutoSave: async () => { // TODO: Implement clearing autosave from storage // Make sure to stop the timer get().stopWorkoutTimer(); // Preserve favorites when resetting const favoriteIds = get().favoriteIds; const favoritesLoaded = get().favoritesLoaded; set({ ...initialState, favoriteIds, favoritesLoaded }); }, // New actions for minimized state minimizeWorkout: () => { set({ isMinimized: true }); }, maximizeWorkout: () => { set({ isMinimized: false }); }, reset: () => { // Make sure to stop the timer get().stopWorkoutTimer(); // Preserve favorites when resetting const favoriteIds = get().favoriteIds; const favoritesLoaded = get().favoritesLoaded; set({ ...initialState, favoriteIds, favoritesLoaded }); } })); // Helper functions async function getTemplate(templateId: string): Promise { try { // Try to get it from favorites in the database const db = openDatabaseSync('powr.db'); const favoritesService = new FavoritesService(db); const exerciseService = new ExerciseService(db); const templateService = new TemplateService(db, new ExerciseService(db)); // First try to get from favorites const favoriteResult = await favoritesService.getContentById('template', templateId); if (favoriteResult) { return favoriteResult; } // If not in favorites, try the templates table const templateResult = await templateService.getTemplate(templateId); return templateResult; } catch (error) { console.error('Error fetching template:', error); return null; } } /** * Save a workout to the database */ async function saveWorkout(workout: Workout): Promise { try { const db = openDatabaseSync('powr.db'); const workoutService = new WorkoutService(db); await workoutService.saveWorkout(workout); } catch (error) { console.error('Error saving workout:', error); throw error; } } /** * Calculate summary statistics for a workout */ function calculateWorkoutSummary(workout: Workout): WorkoutSummary { const startTime = workout.startTime; const endTime = workout.endTime || Date.now(); const duration = endTime - startTime; let totalVolume = 0; let totalReps = 0; let averageRpe = 0; let rpeCount = 0; const exerciseSummaries = workout.exercises.map(exercise => { let exerciseVolume = 0; let exerciseReps = 0; let exerciseRpe = 0; let rpeCount = 0; let peakWeight = 0; const completedSets = exercise.sets.filter(set => set.isCompleted).length; exercise.sets.forEach(set => { if (set.isCompleted) { const weight = set.weight || 0; const reps = set.reps || 0; exerciseVolume += weight * reps; exerciseReps += reps; if (weight > peakWeight) { peakWeight = weight; } if (set.rpe) { exerciseRpe += set.rpe; rpeCount++; } } }); totalVolume += exerciseVolume; totalReps += exerciseReps; if (rpeCount > 0) { const avgRpe = exerciseRpe / rpeCount; averageRpe += avgRpe; rpeCount++; } return { exerciseId: exercise.id, title: exercise.title, setCount: exercise.sets.length, completedSets, volume: exerciseVolume, peakWeight: peakWeight > 0 ? peakWeight : undefined, totalReps: exerciseReps, averageRpe: rpeCount > 0 ? exerciseRpe / rpeCount : undefined }; }); return { id: workout.id, title: workout.title, type: workout.type, duration, startTime, endTime, exerciseCount: workout.exercises.length, completedExercises: workout.exercises.filter(ex => ex.isCompleted).length, totalVolume, totalReps, averageRpe: rpeCount > 0 ? averageRpe / rpeCount : undefined, exerciseSummaries, personalRecords: [] // Personal records would be calculated separately }; } /** * Save workout summary to the database */ async function saveSummary(summary: WorkoutSummary): Promise { try { const db = openDatabaseSync('powr.db'); const workoutService = new WorkoutService(db); await workoutService.saveWorkoutSummary(summary.id, summary); } catch (error) { console.error('Error saving workout summary:', error); // Non-fatal error, can continue } } // Create a version with selectors for easier state access export const useWorkoutStore = createSelectors(useWorkoutStoreBase);