// lib/services/AnalyticsService.ts import { Workout } from '@/types/workout'; import { WorkoutService } from '@/lib/db/services/WorkoutService'; import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService'; import { NostrWorkoutHistoryService } from '@/lib/db/services/NostrWorkoutHistoryService'; import { UnifiedWorkoutHistoryService } from '@/lib/db/services/UnifiedWorkoutHistoryService'; /** * Workout statistics data structure */ export interface WorkoutStats { workoutCount: number; totalDuration: number; // in milliseconds totalVolume: number; averageIntensity: number; exerciseDistribution: Record; frequencyByDay: number[]; // 0 = Sunday, 6 = Saturday } /** * Analytics data for a specific period */ export interface AnalyticsData { date: Date; workoutCount: number; totalVolume: number; totalDuration: number; exerciseCount: number; } /** * Summary statistics for the profile overview */ export interface SummaryStatistics { totalWorkouts: number; totalVolume: number; totalExercises: number; averageDuration: number; currentStreak: number; longestStreak: number; } /** * Progress point for tracking exercise progress */ export interface ProgressPoint { date: number; // timestamp value: number; workoutId: string; } /** * Personal record data structure */ export interface PersonalRecord { id: string; exerciseId: string; exerciseName: string; value: number; unit: string; reps: number; date: number; // timestamp workoutId: string; previousRecord?: { value: number; date: number; }; metric?: 'weight' | 'reps' | 'volume'; } /** * Exercise progress data structure */ export interface ExerciseProgress { exerciseId: string; exerciseName: string; dataPoints: { date: Date; value: number; workoutId: string; }[]; } /** * Service for calculating workout analytics and progress data */ export class AnalyticsService { private workoutService: WorkoutService | null = null; private nostrWorkoutService: NostrWorkoutService | null = null; private nostrWorkoutHistoryService: NostrWorkoutHistoryService | null = null; private unifiedWorkoutHistoryService: UnifiedWorkoutHistoryService | null = null; private cache = new Map(); private includeNostr: boolean = true; // Set the workout service (called from React components) setWorkoutService(service: WorkoutService): void { this.workoutService = service; } // Set the Nostr workout service (called from React components) setNostrWorkoutService(service: NostrWorkoutService): void { this.nostrWorkoutService = service; } // Set the Nostr workout history service (called from React components) setNostrWorkoutHistoryService(service: NostrWorkoutHistoryService | UnifiedWorkoutHistoryService): void { if (service instanceof NostrWorkoutHistoryService) { this.nostrWorkoutHistoryService = service; } else { this.unifiedWorkoutHistoryService = service; } } // Set the Unified workout history service (called from React components) setUnifiedWorkoutHistoryService(service: UnifiedWorkoutHistoryService): void { this.unifiedWorkoutHistoryService = service; } // Set whether to include Nostr workouts in analytics setIncludeNostr(include: boolean): void { this.includeNostr = include; this.invalidateCache(); // Clear cache when this setting changes } /** * Get workout statistics for a given period */ async getWorkoutStats(period: 'week' | 'month' | 'year' | 'all'): Promise { const cacheKey = `stats-${period}`; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get workouts for the period const workouts = await this.getWorkoutsForPeriod(period); // Calculate statistics const stats: WorkoutStats = { workoutCount: workouts.length, totalDuration: 0, totalVolume: 0, averageIntensity: 0, exerciseDistribution: {}, frequencyByDay: [0, 0, 0, 0, 0, 0, 0], }; // Process workouts workouts.forEach(workout => { // Add duration stats.totalDuration += (workout.endTime || Date.now()) - workout.startTime; // Add volume stats.totalVolume += workout.totalVolume || 0; // Track frequency by day const day = new Date(workout.startTime).getDay(); stats.frequencyByDay[day]++; // Track exercise distribution workout.exercises?.forEach(exercise => { const exerciseId = exercise.id; stats.exerciseDistribution[exerciseId] = (stats.exerciseDistribution[exerciseId] || 0) + 1; }); }); // Calculate average intensity stats.averageIntensity = workouts.length > 0 ? workouts.reduce((sum, workout) => sum + (workout.averageRpe || 0), 0) / workouts.length : 0; this.cache.set(cacheKey, stats); return stats; } /** * Get progress for a specific exercise */ async getExerciseProgress( exerciseId: string, metric: 'weight' | 'reps' | 'volume', period: 'month' | 'year' | 'all' ): Promise { const cacheKey = `progress-${exerciseId}-${metric}-${period}`; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get workouts for the period const workouts = await this.getWorkoutsForPeriod(period); // Filter workouts that contain the exercise const relevantWorkouts = workouts.filter(workout => workout.exercises?.some(exercise => exercise.id === exerciseId || exercise.exerciseId === exerciseId ) ); // Extract progress points const progressPoints: ProgressPoint[] = []; relevantWorkouts.forEach(workout => { const exercise = workout.exercises?.find(e => e.id === exerciseId || e.exerciseId === exerciseId ); if (!exercise) return; let value = 0; switch (metric) { case 'weight': // Find the maximum weight used in any set value = Math.max(...exercise.sets.map(set => set.weight || 0)); break; case 'reps': // Find the maximum reps in any set value = Math.max(...exercise.sets.map(set => set.reps || 0)); break; case 'volume': // Calculate total volume (weight * reps) for the exercise value = exercise.sets.reduce((sum, set) => sum + ((set.weight || 0) * (set.reps || 0)), 0); break; } progressPoints.push({ date: workout.startTime, value, workoutId: workout.id }); }); // Sort by date progressPoints.sort((a, b) => a.date - b.date); this.cache.set(cacheKey, progressPoints); return progressPoints; } /** * Get personal records for exercises */ async getPersonalRecords( exerciseIds?: string[], limit?: number ): Promise { const cacheKey = `records-${exerciseIds?.join('-') || 'all'}-${limit || 'all'}`; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get all workouts const workouts = await this.getWorkoutsForPeriod('all'); // Track personal records by exercise const recordsByExercise = new Map(); const previousRecords = new Map(); // Process workouts in chronological order workouts.sort((a, b) => a.startTime - b.startTime); workouts.forEach(workout => { workout.exercises?.forEach(exercise => { // Skip if we're filtering by exerciseIds and this one isn't included if (exerciseIds && !exerciseIds.includes(exercise.id) && !exerciseIds.includes(exercise.exerciseId || '')) { return; } // Find the maximum weight used in any set const maxWeightSet = exercise.sets.reduce((max, set) => { if (!set.weight) return max; if (!max || set.weight > max.weight) { return { weight: set.weight, reps: set.reps || 0 }; } return max; }, null as { weight: number; reps: number } | null); if (!maxWeightSet) return; const exerciseId = exercise.exerciseId || exercise.id; const currentRecord = recordsByExercise.get(exerciseId); // Check if this is a new record if (!currentRecord || maxWeightSet.weight > currentRecord.value) { // Save the previous record if (currentRecord) { previousRecords.set(exerciseId, { value: currentRecord.value, date: currentRecord.date }); } // Create new record recordsByExercise.set(exerciseId, { id: `pr-${exerciseId}-${workout.id}`, exerciseId, exerciseName: exercise.title, value: maxWeightSet.weight, unit: 'lb', reps: maxWeightSet.reps, date: workout.startTime, workoutId: workout.id, previousRecord: previousRecords.get(exerciseId) }); } }); }); // Convert to array and sort by date (most recent first) let records = Array.from(recordsByExercise.values()) .sort((a, b) => b.date - a.date); // Apply limit if specified if (limit) { records = records.slice(0, limit); } this.cache.set(cacheKey, records); return records; } /** * Helper method to get workouts for a period */ private async getWorkoutsForPeriod(period: 'week' | 'month' | 'year' | 'all'): Promise { const now = new Date(); let startDate: Date; switch (period) { case 'week': startDate = new Date(now); startDate.setDate(now.getDate() - 7); break; case 'month': startDate = new Date(now); startDate.setMonth(now.getMonth() - 1); break; case 'year': startDate = new Date(now); startDate.setFullYear(now.getFullYear() - 1); break; case 'all': default: startDate = new Date(0); // Beginning of time break; } // If we have the UnifiedWorkoutHistoryService, use it to get all workouts if (this.unifiedWorkoutHistoryService) { return this.unifiedWorkoutHistoryService.getAllWorkouts({ includeNostr: this.includeNostr, isAuthenticated: true }); } // Fallback to NostrWorkoutHistoryService if UnifiedWorkoutHistoryService is not available if (this.nostrWorkoutHistoryService) { return this.nostrWorkoutHistoryService.getAllWorkouts({ includeNostr: this.includeNostr, isAuthenticated: true }); } // Fallback to using WorkoutService if NostrWorkoutHistoryService is not available let localWorkouts: Workout[] = []; if (this.workoutService) { localWorkouts = await this.workoutService.getWorkoutsByDateRange(startDate.getTime(), now.getTime()); } // In a real implementation, we would also fetch Nostr workouts // const nostrWorkouts = await this.nostrWorkoutService?.getWorkoutsByDateRange(startDate.getTime(), now.getTime()); const nostrWorkouts: Workout[] = []; // Combine and deduplicate workouts const allWorkouts = [...localWorkouts]; // Add Nostr workouts that aren't already in local workouts for (const nostrWorkout of nostrWorkouts) { if (!allWorkouts.some(w => w.id === nostrWorkout.id)) { allWorkouts.push(nostrWorkout); } } return allWorkouts.sort((a, b) => b.startTime - a.startTime); } /** * Get workout frequency data for a specific period */ async getWorkoutFrequency(period: 'daily' | 'weekly' | 'monthly', limit: number = 30): Promise { const cacheKey = `frequency-${period}-${limit}`; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); const now = new Date(); let startDate: Date; // Determine date range based on period switch (period) { case 'daily': startDate = new Date(now); startDate.setDate(now.getDate() - limit); break; case 'weekly': startDate = new Date(now); startDate.setDate(now.getDate() - (limit * 7)); break; case 'monthly': startDate = new Date(now); startDate.setMonth(now.getMonth() - limit); break; } // Get workouts in the date range const workouts = await this.getWorkoutsForPeriod('all'); const filteredWorkouts = workouts.filter(w => w.startTime >= startDate.getTime()); // Group workouts by period const groupedData = new Map(); filteredWorkouts.forEach(workout => { const date = new Date(workout.startTime); let key: string; // Format date key based on period switch (period) { case 'daily': key = date.toISOString().split('T')[0]; // YYYY-MM-DD break; case 'weekly': // Get the week number (approximate) const weekNum = Math.floor(date.getDate() / 7); key = `${date.getFullYear()}-${date.getMonth() + 1}-W${weekNum}`; break; case 'monthly': key = `${date.getFullYear()}-${date.getMonth() + 1}`; break; } // Initialize or update group data if (!groupedData.has(key)) { groupedData.set(key, { date: new Date(date), workoutCount: 0, totalVolume: 0, totalDuration: 0, exerciseCount: 0 }); } const data = groupedData.get(key)!; data.workoutCount++; data.totalVolume += workout.totalVolume || 0; data.totalDuration += (workout.endTime || date.getTime()) - workout.startTime; data.exerciseCount += workout.exercises?.length || 0; }); // Convert to array and sort by date const result = Array.from(groupedData.values()) .sort((a, b) => a.date.getTime() - b.date.getTime()); this.cache.set(cacheKey, result); return result; } /** * Get volume progression data for a specific period */ async getVolumeProgression(period: 'daily' | 'weekly' | 'monthly', limit: number = 30): Promise { // This uses the same data as getWorkoutFrequency but is separated for clarity return this.getWorkoutFrequency(period, limit); } /** * Get streak metrics (current and longest streak) */ async getStreakMetrics(): Promise<{ current: number; longest: number }> { const cacheKey = 'streak-metrics'; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get all workouts const workouts = await this.getWorkoutsForPeriod('all'); // Extract dates and sort them const dates = workouts.map(w => new Date(w.startTime).toISOString().split('T')[0]); const uniqueDates = [...new Set(dates)].sort(); // Calculate current streak let currentStreak = 0; let longestStreak = 0; let currentStreakCount = 0; // Get today's date in YYYY-MM-DD format const today = new Date().toISOString().split('T')[0]; // Check if the most recent workout was today or yesterday if (uniqueDates.length > 0) { const lastWorkoutDate = uniqueDates[uniqueDates.length - 1]; const lastWorkoutTime = new Date(lastWorkoutDate).getTime(); const todayTime = new Date(today).getTime(); // If the last workout was within the last 48 hours, count the streak if (todayTime - lastWorkoutTime <= 48 * 60 * 60 * 1000) { currentStreakCount = 1; // Count consecutive days backwards for (let i = uniqueDates.length - 2; i >= 0; i--) { const currentDate = new Date(uniqueDates[i]); const nextDate = new Date(uniqueDates[i + 1]); // Check if dates are consecutive const diffTime = nextDate.getTime() - currentDate.getTime(); const diffDays = diffTime / (1000 * 60 * 60 * 24); if (diffDays <= 1.1) { // Allow for some time zone differences currentStreakCount++; } else { break; } } } } // Calculate longest streak let tempStreak = 1; for (let i = 1; i < uniqueDates.length; i++) { const currentDate = new Date(uniqueDates[i - 1]); const nextDate = new Date(uniqueDates[i]); // Check if dates are consecutive const diffTime = nextDate.getTime() - currentDate.getTime(); const diffDays = diffTime / (1000 * 60 * 60 * 24); if (diffDays <= 1.1) { // Allow for some time zone differences tempStreak++; } else { if (tempStreak > longestStreak) { longestStreak = tempStreak; } tempStreak = 1; } } // Check if the final streak is the longest if (tempStreak > longestStreak) { longestStreak = tempStreak; } const result = { current: currentStreakCount, longest: longestStreak }; this.cache.set(cacheKey, result); return result; } /** * Get summary statistics for the profile overview */ async getSummaryStatistics(): Promise { const cacheKey = 'summary-statistics'; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get all workouts const workouts = await this.getWorkoutsForPeriod('all'); // Calculate total workouts const totalWorkouts = workouts.length; // Calculate total volume const totalVolume = workouts.reduce((sum, workout) => sum + (workout.totalVolume || 0), 0); // Calculate total unique exercises const exerciseIds = new Set(); workouts.forEach(workout => { workout.exercises?.forEach(exercise => { exerciseIds.add(exercise.exerciseId || exercise.id); }); }); const totalExercises = exerciseIds.size; // Calculate average duration const totalDuration = workouts.reduce((sum, workout) => { const duration = (workout.endTime || workout.startTime) - workout.startTime; return sum + duration; }, 0); const averageDuration = totalWorkouts > 0 ? totalDuration / totalWorkouts : 0; // Get streak metrics const { current, longest } = await this.getStreakMetrics(); const result = { totalWorkouts, totalVolume, totalExercises, averageDuration, currentStreak: current, longestStreak: longest }; this.cache.set(cacheKey, result); return result; } /** * Get most frequent exercises */ async getMostFrequentExercises(limit: number = 5): Promise<{ exerciseId: string; exerciseName: string; count: number }[]> { const cacheKey = `frequent-exercises-${limit}`; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get all workouts const workouts = await this.getWorkoutsForPeriod('all'); // Count exercise occurrences const exerciseCounts = new Map(); workouts.forEach(workout => { workout.exercises?.forEach(exercise => { const id = exercise.exerciseId || exercise.id; if (!exerciseCounts.has(id)) { exerciseCounts.set(id, { name: exercise.title, count: 0 }); } exerciseCounts.get(id)!.count++; }); }); // Convert to array and sort by count const result = Array.from(exerciseCounts.entries()) .map(([id, data]) => ({ exerciseId: id, exerciseName: data.name, count: data.count })) .sort((a, b) => b.count - a.count) .slice(0, limit); this.cache.set(cacheKey, result); return result; } /** * Get workout distribution by day of week */ async getWorkoutsByDayOfWeek(): Promise<{ day: number; count: number }[]> { const cacheKey = 'workouts-by-day'; if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); // Get all workouts const workouts = await this.getWorkoutsForPeriod('all'); // Initialize counts for each day (0 = Sunday, 6 = Saturday) const dayCounts = [0, 0, 0, 0, 0, 0, 0]; // Count workouts by day workouts.forEach(workout => { const day = new Date(workout.startTime).getDay(); dayCounts[day]++; }); // Convert to result format const result = dayCounts.map((count, day) => ({ day, count })); this.cache.set(cacheKey, result); return result; } /** * Invalidate cache when new workouts are added */ invalidateCache(): void { this.cache.clear(); } } // Create a singleton instance export const analyticsService = new AnalyticsService();