// lib/db/services/EnhancedWorkoutHistoryService.ts import { SQLiteDatabase } from 'expo-sqlite'; import { Workout } from '@/types/workout'; import { format, startOfDay, endOfDay, startOfMonth, endOfMonth } from 'date-fns'; import { DbService } from '../db-service'; import { WorkoutExercise } from '@/types/exercise'; // Define workout filter interface export interface WorkoutFilters { type?: string[]; dateRange?: { start: Date; end: Date }; exercises?: string[]; tags?: string[]; source?: ('local' | 'nostr' | 'both')[]; searchTerm?: string; } // Define workout sync status interface export interface WorkoutSyncStatus { isLocal: boolean; isPublished: boolean; eventId?: string; relayCount?: number; lastPublished?: number; } // Define export format type export type ExportFormat = 'csv' | 'json'; export class WorkoutHistoryService { private db: DbService; constructor(database: SQLiteDatabase) { this.db = new DbService(database); } /** * Search workouts by title, exercise name, or notes */ async searchWorkouts(query: string): Promise { try { if (!query || query.trim() === '') { return this.getAllWorkouts(); } const searchTerm = `%${query.trim()}%`; // Search in workout titles and notes const workoutIds = await this.db.getAllAsync<{ id: string }>( `SELECT DISTINCT w.id FROM workouts w LEFT JOIN workout_exercises we ON w.id = we.workout_id LEFT JOIN exercises e ON we.exercise_id = e.id WHERE w.title LIKE ? OR w.notes LIKE ? OR e.title LIKE ? ORDER BY w.start_time DESC`, [searchTerm, searchTerm, searchTerm] ); // Get full workout details for each matching ID const result: Workout[] = []; for (const { id } of workoutIds) { const workout = await this.getWorkoutDetails(id); if (workout) { result.push(workout); } } return result; } catch (error) { console.error('Error searching workouts:', error); throw error; } } /** * Filter workouts based on various criteria */ async filterWorkouts(filters: WorkoutFilters): Promise { try { // Start with a base query let query = ` SELECT DISTINCT w.* FROM workouts w `; const params: any[] = []; const conditions: string[] = []; // Add joins if needed if (filters.exercises && filters.exercises.length > 0) { query += ` JOIN workout_exercises we ON w.id = we.workout_id `; } if (filters.tags && filters.tags.length > 0) { query += ` JOIN workout_exercises we2 ON w.id = we2.workout_id JOIN exercise_tags et ON we2.exercise_id = et.exercise_id `; } // Add type filter if (filters.type && filters.type.length > 0) { conditions.push(`w.type IN (${filters.type.map(() => '?').join(', ')})`); params.push(...filters.type); } // Add date range filter if (filters.dateRange) { const { start, end } = filters.dateRange; conditions.push('w.start_time >= ? AND w.start_time <= ?'); params.push(startOfDay(start).getTime(), endOfDay(end).getTime()); } // Add exercise filter if (filters.exercises && filters.exercises.length > 0) { conditions.push(`we.exercise_id IN (${filters.exercises.map(() => '?').join(', ')})`); params.push(...filters.exercises); } // Add tag filter if (filters.tags && filters.tags.length > 0) { conditions.push(`et.tag IN (${filters.tags.map(() => '?').join(', ')})`); params.push(...filters.tags); } // Add source filter if (filters.source && filters.source.length > 0) { // Handle 'both' specially if (filters.source.includes('both')) { conditions.push(`(w.source = 'local' OR w.source = 'nostr')`); } else { conditions.push(`w.source IN (${filters.source.map(() => '?').join(', ')})`); params.push(...filters.source); } } // Add search term filter if (filters.searchTerm && filters.searchTerm.trim() !== '') { const searchTerm = `%${filters.searchTerm.trim()}%`; conditions.push('(w.title LIKE ? OR w.notes LIKE ?)'); params.push(searchTerm, searchTerm); } // Add WHERE clause if there are conditions if (conditions.length > 0) { query += ` WHERE ${conditions.join(' AND ')}`; } // Add ORDER BY query += ' ORDER BY w.start_time DESC'; // Execute the query const workouts = await this.db.getAllAsync<{ id: string; title: string; type: string; start_time: number; end_time: number | null; is_completed: number; created_at: number; updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; notes: string | null; nostr_event_id: string | null; nostr_published_at: number | null; nostr_relay_count: number | null; }>(query, params); // Transform database records to Workout objects const result: Workout[] = []; for (const workout of workouts) { const exercises = await this.getWorkoutExercises(workout.id); result.push({ id: workout.id, title: workout.title, type: workout.type as any, startTime: workout.start_time, endTime: workout.end_time || undefined, isCompleted: Boolean(workout.is_completed), created_at: workout.created_at, lastUpdated: workout.updated_at, templateId: workout.template_id || undefined, totalVolume: workout.total_volume || undefined, totalReps: workout.total_reps || undefined, notes: workout.notes || undefined, exercises, availability: { source: [workout.source as any], nostrEventId: workout.nostr_event_id || undefined, nostrPublishedAt: workout.nostr_published_at || undefined, nostrRelayCount: workout.nostr_relay_count || undefined } }); } return result; } catch (error) { console.error('Error filtering workouts:', error); throw error; } } /** * Get workouts that include a specific exercise */ async getWorkoutsByExercise(exerciseId: string): Promise { try { const workoutIds = await this.db.getAllAsync<{ workout_id: string }>( `SELECT DISTINCT workout_id FROM workout_exercises WHERE exercise_id = ? ORDER BY created_at DESC`, [exerciseId] ); const result: Workout[] = []; for (const { workout_id } of workoutIds) { const workout = await this.getWorkoutDetails(workout_id); if (workout) { result.push(workout); } } return result; } catch (error) { console.error('Error getting workouts by exercise:', error); throw error; } } /** * Export workout history in the specified format */ async exportWorkoutHistory(format: ExportFormat): Promise { try { const workouts = await this.getAllWorkouts(); if (format === 'json') { return JSON.stringify(workouts, null, 2); } else if (format === 'csv') { // Create CSV header let csv = 'ID,Title,Type,Start Time,End Time,Completed,Volume,Reps,Notes\n'; // Add workout rows for (const workout of workouts) { const row = [ workout.id, `"${workout.title.replace(/"/g, '""')}"`, // Escape quotes in title workout.type, new Date(workout.startTime).toISOString(), workout.endTime ? new Date(workout.endTime).toISOString() : '', workout.isCompleted ? 'Yes' : 'No', workout.totalVolume || '', workout.totalReps || '', workout.notes ? `"${workout.notes.replace(/"/g, '""')}"` : '' // Escape quotes in notes ]; csv += row.join(',') + '\n'; } return csv; } throw new Error(`Unsupported export format: ${format}`); } catch (error) { console.error('Error exporting workout history:', error); throw error; } } /** * Get workout streak data */ async getWorkoutStreak(): Promise<{ current: number; longest: number; lastWorkoutDate: Date | null }> { try { // Get all workout dates sorted by date const workoutDates = await this.db.getAllAsync<{ workout_date: number }>( `SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as workout_date FROM workouts ORDER BY workout_date DESC` ); if (workoutDates.length === 0) { return { current: 0, longest: 0, lastWorkoutDate: null }; } const dates = workoutDates.map(row => new Date(row.workout_date)); // Calculate current streak let currentStreak = 1; let lastDate = dates[0]; for (let i = 1; i < dates.length; i++) { const currentDate = dates[i]; const dayDiff = Math.floor((lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)); if (dayDiff === 1) { // Consecutive day currentStreak++; lastDate = currentDate; } else if (dayDiff > 1) { // Streak broken break; } } // Calculate longest streak let longestStreak = 1; let currentLongestStreak = 1; lastDate = dates[0]; for (let i = 1; i < dates.length; i++) { const currentDate = dates[i]; const dayDiff = Math.floor((lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24)); if (dayDiff === 1) { // Consecutive day currentLongestStreak++; lastDate = currentDate; if (currentLongestStreak > longestStreak) { longestStreak = currentLongestStreak; } } else if (dayDiff > 1) { // Streak broken currentLongestStreak = 1; lastDate = currentDate; } } return { current: currentStreak, longest: longestStreak, lastWorkoutDate: dates[0] }; } catch (error) { console.error('Error getting workout streak:', error); return { current: 0, longest: 0, lastWorkoutDate: null }; } } /** * Get workout sync status */ async getWorkoutSyncStatus(workoutId: string): Promise { try { const workout = await this.db.getFirstAsync<{ source: string; nostr_event_id: string | null; nostr_published_at: number | null; nostr_relay_count: number | null; }>( `SELECT source, nostr_event_id, nostr_published_at, nostr_relay_count FROM workouts WHERE id = ?`, [workoutId] ); if (!workout) { return { isLocal: false, isPublished: false }; } return { isLocal: workout.source === 'local' || workout.source === 'both', isPublished: Boolean(workout.nostr_event_id), eventId: workout.nostr_event_id || undefined, relayCount: workout.nostr_relay_count || undefined, lastPublished: workout.nostr_published_at || undefined }; } catch (error) { console.error('Error getting workout sync status:', error); return { isLocal: false, isPublished: false }; } } /** * Publish a workout to Nostr * This method updates the database with Nostr publication details * The actual publishing is handled by NostrWorkoutService */ async updateWorkoutNostrStatus( workoutId: string, eventId: string, relayCount: number ): Promise { try { await this.db.runAsync( `UPDATE workouts SET nostr_event_id = ?, nostr_published_at = ?, nostr_relay_count = ? WHERE id = ?`, [eventId, Date.now(), relayCount, workoutId] ); return true; } catch (error) { console.error('Error updating workout Nostr status:', error); return false; } } /** * Get all workouts, sorted by date in descending order */ async getAllWorkouts(): Promise { try { const workouts = await this.db.getAllAsync<{ id: string; title: string; type: string; start_time: number; end_time: number | null; is_completed: number; created_at: number; updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; notes: string | null; nostr_event_id: string | null; nostr_published_at: number | null; nostr_relay_count: number | null; }>( `SELECT * FROM workouts ORDER BY start_time DESC` ); // Transform database records to Workout objects const result: Workout[] = []; for (const workout of workouts) { const exercises = await this.getWorkoutExercises(workout.id); result.push({ id: workout.id, title: workout.title, type: workout.type as any, startTime: workout.start_time, endTime: workout.end_time || undefined, isCompleted: Boolean(workout.is_completed), created_at: workout.created_at, lastUpdated: workout.updated_at, templateId: workout.template_id || undefined, totalVolume: workout.total_volume || undefined, totalReps: workout.total_reps || undefined, notes: workout.notes || undefined, exercises, availability: { source: [workout.source as any], nostrEventId: workout.nostr_event_id || undefined, nostrPublishedAt: workout.nostr_published_at || undefined, nostrRelayCount: workout.nostr_relay_count || undefined } }); } return result; } catch (error) { console.error('Error getting workouts:', error); throw error; } } /** * Get workouts for a specific date */ async getWorkoutsByDate(date: Date): Promise { try { const startOfDayTime = startOfDay(date).getTime(); const endOfDayTime = endOfDay(date).getTime(); const workouts = await this.db.getAllAsync<{ id: string; title: string; type: string; start_time: number; end_time: number | null; is_completed: number; created_at: number; updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; notes: string | null; nostr_event_id: string | null; nostr_published_at: number | null; nostr_relay_count: number | null; }>( `SELECT * FROM workouts WHERE start_time >= ? AND start_time <= ? ORDER BY start_time DESC`, [startOfDayTime, endOfDayTime] ); const result: Workout[] = []; for (const workout of workouts) { const exercises = await this.getWorkoutExercises(workout.id); result.push({ id: workout.id, title: workout.title, type: workout.type as any, startTime: workout.start_time, endTime: workout.end_time || undefined, isCompleted: Boolean(workout.is_completed), created_at: workout.created_at, lastUpdated: workout.updated_at, templateId: workout.template_id || undefined, totalVolume: workout.total_volume || undefined, totalReps: workout.total_reps || undefined, notes: workout.notes || undefined, exercises, availability: { source: [workout.source as any], nostrEventId: workout.nostr_event_id || undefined, nostrPublishedAt: workout.nostr_published_at || undefined, nostrRelayCount: workout.nostr_relay_count || undefined } }); } return result; } catch (error) { console.error('Error getting workouts by date:', error); throw error; } } /** * Get all dates that have workouts within a month */ async getWorkoutDatesInMonth(year: number, month: number): Promise { try { const startOfMonthTime = startOfMonth(new Date(year, month)).getTime(); const endOfMonthTime = endOfMonth(new Date(year, month)).getTime(); const result = await this.db.getAllAsync<{ start_time: number; }>( `SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as start_time FROM workouts WHERE start_time >= ? AND start_time <= ?`, [startOfMonthTime, endOfMonthTime] ); return result.map(row => new Date(row.start_time)); } catch (error) { console.error('Error getting workout dates:', error); return []; } } /** * Get workout details including exercises */ async getWorkoutDetails(workoutId: string): Promise { try { const workout = await this.db.getFirstAsync<{ id: string; title: string; type: string; start_time: number; end_time: number | null; is_completed: number; created_at: number; updated_at: number; template_id: string | null; total_volume: number | null; total_reps: number | null; source: string; notes: string | null; nostr_event_id: string | null; nostr_published_at: number | null; nostr_relay_count: number | null; }>( `SELECT * FROM workouts WHERE id = ?`, [workoutId] ); if (!workout) return null; // Get exercises for this workout const exercises = await this.getWorkoutExercises(workoutId); return { id: workout.id, title: workout.title, type: workout.type as any, startTime: workout.start_time, endTime: workout.end_time || undefined, isCompleted: Boolean(workout.is_completed), created_at: workout.created_at, lastUpdated: workout.updated_at, templateId: workout.template_id || undefined, totalVolume: workout.total_volume || undefined, totalReps: workout.total_reps || undefined, notes: workout.notes || undefined, exercises, availability: { source: [workout.source as any], nostrEventId: workout.nostr_event_id || undefined, nostrPublishedAt: workout.nostr_published_at || undefined, nostrRelayCount: workout.nostr_relay_count || undefined } }; } catch (error) { console.error('Error getting workout details:', error); throw error; } } /** * Get the total number of workouts */ async getWorkoutCount(): Promise { try { const result = await this.db.getFirstAsync<{ count: number }>( `SELECT COUNT(*) as count FROM workouts` ); return result?.count || 0; } catch (error) { console.error('Error getting workout count:', error); return 0; } } // Helper method to load workout exercises and sets private async getWorkoutExercises(workoutId: string): Promise { try { console.log(`[EnhancedWorkoutHistoryService] Getting exercises for workout: ${workoutId}`); const exercises = await this.db.getAllAsync<{ id: string; exercise_id: string; display_order: number; notes: string | null; created_at: number; updated_at: number; }>( `SELECT we.* FROM workout_exercises we WHERE we.workout_id = ? ORDER BY we.display_order`, [workoutId] ); console.log(`[EnhancedWorkoutHistoryService] Found ${exercises.length} exercises for workout ${workoutId}`); const result: WorkoutExercise[] = []; for (const exercise of exercises) { console.log(`[EnhancedWorkoutHistoryService] Processing exercise: ${exercise.id}, exercise_id: ${exercise.exercise_id}`); // Get the base exercise info const baseExercise = await this.db.getFirstAsync<{ title: string; type: string; category: string; equipment: string | null; }>( `SELECT title, type, category, equipment FROM exercises WHERE id = ?`, [exercise.exercise_id] ); console.log(`[EnhancedWorkoutHistoryService] Base exercise lookup result: ${baseExercise ? JSON.stringify(baseExercise) : 'null'}`); // If base exercise not found, check if it exists in the exercises table if (!baseExercise) { const exerciseExists = await this.db.getFirstAsync<{ count: number }>( `SELECT COUNT(*) as count FROM exercises WHERE id = ?`, [exercise.exercise_id] ); console.log(`[EnhancedWorkoutHistoryService] Exercise ${exercise.exercise_id} exists in exercises table: ${(exerciseExists?.count ?? 0) > 0}`); } // Get the tags for this exercise const tags = await this.db.getAllAsync<{ tag: string }>( `SELECT tag FROM exercise_tags WHERE exercise_id = ?`, [exercise.exercise_id] ); console.log(`[EnhancedWorkoutHistoryService] Found ${tags.length} tags for exercise ${exercise.exercise_id}`); // Get the sets for this exercise const sets = await this.db.getAllAsync<{ id: string; type: string; weight: number | null; reps: number | null; rpe: number | null; duration: number | null; is_completed: number; completed_at: number | null; created_at: number; updated_at: number; }>( `SELECT * FROM workout_sets WHERE workout_exercise_id = ? ORDER BY id`, [exercise.id] ); console.log(`[EnhancedWorkoutHistoryService] Found ${sets.length} sets for exercise ${exercise.id}`); // Map sets to the correct format const mappedSets = sets.map(set => ({ id: set.id, type: set.type as any, weight: set.weight || undefined, reps: set.reps || undefined, rpe: set.rpe || undefined, duration: set.duration || undefined, isCompleted: Boolean(set.is_completed), completedAt: set.completed_at || undefined, lastUpdated: set.updated_at })); const exerciseTitle = baseExercise?.title || 'Unknown Exercise'; console.log(`[EnhancedWorkoutHistoryService] Using title: ${exerciseTitle} for exercise ${exercise.id}`); result.push({ id: exercise.id, exerciseId: exercise.exercise_id, title: exerciseTitle, type: baseExercise?.type as any || 'strength', category: baseExercise?.category as any || 'Other', equipment: baseExercise?.equipment as any, notes: exercise.notes || undefined, tags: tags.map(t => t.tag), // Add the tags array here sets: mappedSets, created_at: exercise.created_at, lastUpdated: exercise.updated_at, isCompleted: mappedSets.every(set => set.isCompleted), availability: { source: ['local'] } }); } console.log(`[EnhancedWorkoutHistoryService] Returning ${result.length} processed exercises for workout ${workoutId}`); return result; } catch (error) { console.error('[EnhancedWorkoutHistoryService] Error getting workout exercises:', error); return []; } } }