POWR/lib/services/AnalyticsService.ts

673 lines
20 KiB
TypeScript

// 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<string, number>;
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<string, any>();
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<WorkoutStats> {
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<ProgressPoint[]> {
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<PersonalRecord[]> {
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<string, PersonalRecord>();
const previousRecords = new Map<string, { value: number; date: number }>();
// 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<Workout[]> {
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<AnalyticsData[]> {
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<string, AnalyticsData>();
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<AnalyticsData[]> {
// 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<SummaryStatistics> {
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<string>();
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<string, { name: string; count: number }>();
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();