mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
673 lines
20 KiB
TypeScript
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();
|