diff --git a/app/src/main/java/com/jdluu/flexinsight/data/mapper/ExerciseMapper.kt b/app/src/main/java/com/jdluu/flexinsight/data/mapper/ExerciseMapper.kt new file mode 100644 index 0000000..5ce5109 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/mapper/ExerciseMapper.kt @@ -0,0 +1,19 @@ +package com.jdluu.flexinsight.data.mapper + +import com.jdluu.flexinsight.data.model.ExerciseTemplate +import com.jdluu.flexinsight.data.model.ExerciseTemplateResponse + +/** + * Pure mapper for exercise template API responses to domain models. + * Mapping must stay identical to the previous inline implementations. + */ +object ExerciseMapper { + + fun toExerciseTemplate(response: ExerciseTemplateResponse): ExerciseTemplate { + return ExerciseTemplate( + id = response.id, + name = response.title, // Map title to name for internal model + muscleGroup = response.muscleGroup + ) + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/mapper/RoutineMapper.kt b/app/src/main/java/com/jdluu/flexinsight/data/mapper/RoutineMapper.kt new file mode 100644 index 0000000..bf010b8 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/mapper/RoutineMapper.kt @@ -0,0 +1,50 @@ +package com.jdluu.flexinsight.data.mapper + +import com.jdluu.flexinsight.data.model.Routine +import com.jdluu.flexinsight.data.model.RoutineExercise +import com.jdluu.flexinsight.data.model.RoutineExerciseResponse +import com.jdluu.flexinsight.data.model.RoutineFolder +import com.jdluu.flexinsight.data.model.RoutineFolderResponse +import com.jdluu.flexinsight.data.model.RoutineResponse + +/** + * Pure mapper for routine-related API responses to domain models. + * Mapping must stay identical to the previous inline implementations. + */ +object RoutineMapper { + + fun toRoutine( + response: RoutineResponse, + exerciseTemplateMapping: Map = emptyMap() + ): Routine { + val routineExercises = response.exercises?.map { exerciseResponse -> + val exerciseName = exerciseTemplateMapping[exerciseResponse.templateId] + toRoutineExercise(exerciseResponse, exerciseName) + } + + return Routine( + id = response.id, + name = response.name, + exerciseCount = response.exerciseCount, + exercises = routineExercises + ) + } + + fun toRoutineExercise( + response: RoutineExerciseResponse, + exerciseName: String? = null + ): RoutineExercise { + return RoutineExercise( + templateId = response.templateId, + name = response.title ?: exerciseName // Prefer API title, fallback to mapping + ) + } + + fun toRoutineFolder(response: RoutineFolderResponse): RoutineFolder { + return RoutineFolder( + id = response.id, + title = response.title, + index = response.index + ) + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/mapper/WorkoutMapper.kt b/app/src/main/java/com/jdluu/flexinsight/data/mapper/WorkoutMapper.kt new file mode 100644 index 0000000..bee378c --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/mapper/WorkoutMapper.kt @@ -0,0 +1,101 @@ +package com.jdluu.flexinsight.data.mapper + +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.ExerciseResponse +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.SetResponse +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.model.WorkoutResponse + +/** + * Pure mapper for workout-related API responses to domain models. + * Mapping must stay identical to the previous inline implementations. + */ +object WorkoutMapper { + + fun toWorkout(response: WorkoutResponse): Workout { + val startTimestamp = parseTimestamp(response.startTime) + val endTimestamp = response.endTime?.let { parseTimestamp(it) } + + return Workout( + id = response.id, + name = response.title, // API uses "title", we store as "name" + startTime = startTimestamp, + endTime = endTimestamp, + notes = response.description, // API uses "description", we store as "notes" + routineId = response.routineId, + lastSynced = System.currentTimeMillis(), + needsSync = false + ) + } + + fun toExercise(response: ExerciseResponse, workoutId: String): Exercise { + // Generate ID from workout ID and index, or use a hash if index is null + val exerciseId = if (response.index != null) { + "${workoutId}_exercise_${response.index}" + } else { + "${workoutId}_exercise_${response.title.hashCode()}" + } + + return Exercise( + id = exerciseId, + workoutId = workoutId, + exerciseTemplateId = response.exerciseTemplateId, + name = response.title, // API uses "title", we store as "name" + notes = response.notes, + restDuration = response.restSeconds, // API uses "rest_seconds", we store as "restDuration" + lastSynced = System.currentTimeMillis(), + needsSync = false + ) + } + + fun toSet(response: SetResponse, exerciseId: String): Set { + // Generate ID from exercise ID and index + val setId = "${exerciseId}_set_${response.index}" + + return Set( + id = setId, + exerciseId = exerciseId, + number = response.index + 1, // Convert 0-based index to 1-based number + weight = response.weightKg, // API uses "weight_kg", we store as "weight" + reps = response.reps, + rpe = response.rpe, + distance = response.distanceMeters, // API uses "distance_meters", we store as "distance" + duration = response.durationSeconds, // API uses "duration_seconds", we store as "duration" + restDuration = null, // Not provided in API response + notes = response.type, // Store set type as notes for now + isPersonalRecord = response.personalRecord ?: false, + lastSynced = System.currentTimeMillis(), + needsSync = false + ) + } + + private fun parseTimestamp(isoString: String): Long { + return try { + // Parse ISO 8601 format (e.g., "2025-12-12T18:27:13+00:00" or "2024-01-15T10:30:00Z") + // Check if string has timezone offset (contains "+" or has "-" after the date part) + val hasTimezoneOffset = isoString.contains("+") || + (isoString.length > 19 && isoString.substring(19).contains("-")) + + if (hasTimezoneOffset) { + // Format: "2025-12-12T18:27:13+00:00" - replace timezone with Z + val cleanString = isoString.replace(Regex("[+-]\\d{2}:\\d{2}$"), "Z") + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) + dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC") + dateFormat.parse(cleanString)?.time ?: System.currentTimeMillis() + } else { + // Format: "2024-01-15T10:30:00Z" + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) + dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC") + dateFormat.parse(isoString)?.time ?: System.currentTimeMillis() + } + } catch (e: Exception) { + // Fallback: try parsing with java.time if available (Android API 26+) + try { + java.time.Instant.parse(isoString).toEpochMilli() + } catch (e2: Exception) { + System.currentTimeMillis() + } + } + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/model/Exercise.kt b/app/src/main/java/com/jdluu/flexinsight/data/model/Exercise.kt index 584e137..59a1e73 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/model/Exercise.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/model/Exercise.kt @@ -51,27 +51,7 @@ data class ExerciseResponse( val restSeconds: Int?, @SerializedName("sets") val sets: List? -) { - fun toExercise(workoutId: String): Exercise { - // Generate ID from workout ID and index, or use a hash if index is null - val exerciseId = if (index != null) { - "${workoutId}_exercise_$index" - } else { - "${workoutId}_exercise_${title.hashCode()}" - } - - return Exercise( - id = exerciseId, - workoutId = workoutId, - exerciseTemplateId = exerciseTemplateId, - name = title, // API uses "title", we store as "name" - notes = notes, - restDuration = restSeconds, // API uses "rest_seconds", we store as "restDuration" - lastSynced = System.currentTimeMillis(), - needsSync = false - ) - } -} +) /** * Exercise template from Hevy API @@ -92,15 +72,7 @@ data class ExerciseTemplateResponse( val title: String, @SerializedName("muscle_group") val muscleGroup: String? -) { - fun toExerciseTemplate(): ExerciseTemplate { - return ExerciseTemplate( - id = id, - name = title, // Map title to name for internal model - muscleGroup = muscleGroup - ) - } -} +) /** * Paginated response wrapper for exercise templates diff --git a/app/src/main/java/com/jdluu/flexinsight/data/model/Routine.kt b/app/src/main/java/com/jdluu/flexinsight/data/model/Routine.kt index 5d254d7..25d5684 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/model/Routine.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/model/Routine.kt @@ -19,14 +19,7 @@ data class RoutineExerciseResponse( val templateId: String, @SerializedName("title") val title: String? -) { - fun toRoutineExercise(exerciseName: String? = null): RoutineExercise { - return RoutineExercise( - templateId = templateId, - name = title ?: exerciseName // Prefer API title, fallback to mapping - ) - } -} +) /** * Routine - Local representation @@ -51,21 +44,7 @@ data class RoutineResponse( val exerciseCount: Int, @SerializedName("exercises") val exercises: List? -) { - fun toRoutine(exerciseTemplateMapping: Map = emptyMap()): Routine { - val routineExercises = exercises?.map { exerciseResponse -> - val exerciseName = exerciseTemplateMapping[exerciseResponse.templateId] - exerciseResponse.toRoutineExercise(exerciseName) - } - - return Routine( - id = id, - name = name, - exerciseCount = exerciseCount, - exercises = routineExercises - ) - } -} +) /** * Paginated response wrapper for routines diff --git a/app/src/main/java/com/jdluu/flexinsight/data/model/RoutineFolder.kt b/app/src/main/java/com/jdluu/flexinsight/data/model/RoutineFolder.kt index a7f7cc0..ed11089 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/model/RoutineFolder.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/model/RoutineFolder.kt @@ -38,11 +38,3 @@ data class RoutineFolder( val title: String, val index: Int ) - -fun RoutineFolderResponse.toRoutineFolder(): RoutineFolder { - return RoutineFolder( - id = id, - title = title, - index = index - ) -} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/model/Set.kt b/app/src/main/java/com/jdluu/flexinsight/data/model/Set.kt index 6e37d2c..bfedba5 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/model/Set.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/model/Set.kt @@ -62,26 +62,5 @@ data class SetResponse( val customMetric: Double?, @SerializedName("personal_record") val personalRecord: Boolean? = false -) { - fun toSet(exerciseId: String): Set { - // Generate ID from exercise ID and index - val setId = "${exerciseId}_set_$index" - - return Set( - id = setId, - exerciseId = exerciseId, - number = index + 1, // Convert 0-based index to 1-based number - weight = weightKg, // API uses "weight_kg", we store as "weight" - reps = reps, - rpe = rpe, - distance = distanceMeters, // API uses "distance_meters", we store as "distance" - duration = durationSeconds, // API uses "duration_seconds", we store as "duration" - restDuration = null, // Not provided in API response - notes = type, // Store set type as notes for now - isPersonalRecord = personalRecord ?: false, - lastSynced = System.currentTimeMillis(), - needsSync = false - ) - } -} +) diff --git a/app/src/main/java/com/jdluu/flexinsight/data/model/Workout.kt b/app/src/main/java/com/jdluu/flexinsight/data/model/Workout.kt index c0923ad..e4aab95 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/model/Workout.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/model/Workout.kt @@ -47,52 +47,7 @@ data class WorkoutResponse( val routineId: String?, @SerializedName("exercises") val exercises: List? -) { - fun toWorkout(): Workout { - val startTimestamp = parseTimestamp(startTime) - val endTimestamp = endTime?.let { parseTimestamp(it) } - - return Workout( - id = id, - name = title, // API uses "title", we store as "name" - startTime = startTimestamp, - endTime = endTimestamp, - notes = description, // API uses "description", we store as "notes" - routineId = routineId, - lastSynced = System.currentTimeMillis(), - needsSync = false - ) - } - - private fun parseTimestamp(isoString: String): Long { - return try { - // Parse ISO 8601 format (e.g., "2025-12-12T18:27:13+00:00" or "2024-01-15T10:30:00Z") - // Check if string has timezone offset (contains "+" or has "-" after the date part) - val hasTimezoneOffset = isoString.contains("+") || - (isoString.length > 19 && isoString.substring(19).contains("-")) - - if (hasTimezoneOffset) { - // Format: "2025-12-12T18:27:13+00:00" - replace timezone with Z - val cleanString = isoString.replace(Regex("[+-]\\d{2}:\\d{2}$"), "Z") - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) - dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC") - dateFormat.parse(cleanString)?.time ?: System.currentTimeMillis() - } else { - // Format: "2024-01-15T10:30:00Z" - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) - dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC") - dateFormat.parse(isoString)?.time ?: System.currentTimeMillis() - } - } catch (e: Exception) { - // Fallback: try parsing with java.time if available (Android API 26+) - try { - java.time.Instant.parse(isoString).toEpochMilli() - } catch (e2: Exception) { - System.currentTimeMillis() - } - } - } -} +) /** * Paginated response wrapper for workouts diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/ExerciseRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/ExerciseRepositoryImpl.kt index de30f6e..66cca61 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/repository/ExerciseRepositoryImpl.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/ExerciseRepositoryImpl.kt @@ -10,6 +10,7 @@ import com.jdluu.flexinsight.data.cache.CacheKeys import com.jdluu.flexinsight.data.cache.CacheManager import com.jdluu.flexinsight.data.cache.CacheTTL import com.jdluu.flexinsight.data.local.dao.ExerciseDao +import com.jdluu.flexinsight.data.mapper.ExerciseMapper import com.jdluu.flexinsight.data.model.Exercise import com.jdluu.flexinsight.data.model.ExerciseTemplate import com.jdluu.flexinsight.data.model.ExerciseTemplateResponse @@ -165,7 +166,7 @@ class ExerciseRepositoryImpl( } is Result.Success -> { val mapping = templatesResult.data.mapNotNull { templateResponse -> - val template = templateResponse.toExerciseTemplate() + val template = ExerciseMapper.toExerciseTemplate(templateResponse) template.muscleGroup?.let { template.id to it } }.toMap() cacheManager.put(CacheKeys.EXERCISE_TEMPLATES, mapping) diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/RoutineRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/RoutineRepositoryImpl.kt index 5691bb6..57ceaa8 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/repository/RoutineRepositoryImpl.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/RoutineRepositoryImpl.kt @@ -3,7 +3,6 @@ package com.jdluu.flexinsight.data.repository import com.jdluu.flexinsight.core.errors.ApiError import com.jdluu.flexinsight.core.errors.ErrorHandler import com.jdluu.flexinsight.core.errors.Result -import com.jdluu.flexinsight.data.model.toRoutineFolder import com.jdluu.flexinsight.core.logger.AppLogger import com.jdluu.flexinsight.core.network.NetworkMonitor import com.jdluu.flexinsight.data.api.FlexApiClient @@ -11,6 +10,7 @@ import com.jdluu.flexinsight.data.api.FlexApiService import com.jdluu.flexinsight.data.cache.CacheKeys import com.jdluu.flexinsight.data.cache.CacheManager import com.jdluu.flexinsight.data.cache.CacheTTL +import com.jdluu.flexinsight.data.mapper.RoutineMapper import com.jdluu.flexinsight.data.model.Routine import com.jdluu.flexinsight.data.model.RoutineResponse import com.jdluu.flexinsight.data.preferences.ApiKeyManager @@ -109,7 +109,7 @@ class RoutineRepositoryImpl( if (detailResponse.isSuccessful) { val fullRoutineResponse = detailResponse.body() if (fullRoutineResponse != null) { - val routine = fullRoutineResponse.toRoutine(exerciseTemplateMapping) + val routine = RoutineMapper.toRoutine(fullRoutineResponse, exerciseTemplateMapping) allRoutines.add(routine) } } else { @@ -199,7 +199,7 @@ class RoutineRepositoryImpl( emptyMap() } - val routine = routineResponse.toRoutine(exerciseTemplateMapping) + val routine = RoutineMapper.toRoutine(routineResponse, exerciseTemplateMapping) Result.success(routine) } else { val error = ErrorHandler.handleHttpException( @@ -253,7 +253,7 @@ class RoutineRepositoryImpl( val foldersList = paginatedResponse.folders if (foldersList.isNotEmpty()) { - allFolders.addAll(foldersList.map { it.toRoutineFolder() }) + allFolders.addAll(foldersList.map { RoutineMapper.toRoutineFolder(it) }) } // Check if there are more pages - though folders response might not strictly follow pageCount logic, diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsMutationRepository.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsMutationRepository.kt new file mode 100644 index 0000000..24214b5 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsMutationRepository.kt @@ -0,0 +1,8 @@ +package com.jdluu.flexinsight.data.repository + +/** + * Mutation concerns for statistics: invalidating cached derived stats. + */ +interface StatsMutationRepository { + fun invalidateStatsCache() +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsMutationRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsMutationRepositoryImpl.kt new file mode 100644 index 0000000..f155769 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsMutationRepositoryImpl.kt @@ -0,0 +1,20 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.data.cache.CacheKeys +import com.jdluu.flexinsight.data.cache.CacheManager + +/** + * Mutation implementation for statistics: invalidates cached derived stats. + */ +class StatsMutationRepositoryImpl( + private val cacheManager: CacheManager +) : StatsMutationRepository { + override fun invalidateStatsCache() { + cacheManager.invalidatePrefix(CacheKeys.WORKOUT_STATS) + cacheManager.invalidatePrefix(CacheKeys.PRS_WITH_DETAILS) + cacheManager.invalidatePrefix(CacheKeys.MUSCLE_GROUP_PROGRESS) + cacheManager.invalidatePrefix(CacheKeys.WEEKLY_PROGRESS) + cacheManager.invalidatePrefix(CacheKeys.VOLUME_TREND) + cacheManager.invalidatePrefix(CacheKeys.DURATION_TREND) + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsQueryRepository.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsQueryRepository.kt new file mode 100644 index 0000000..e81e4ac --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsQueryRepository.kt @@ -0,0 +1,66 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.MuscleGroup +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.PeriodComparison +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.data.model.PlannedWorkout +import com.jdluu.flexinsight.data.model.ProfileInfo +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.SingleWorkoutStats +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.VolumeTrend +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.WeeklyVolumeData +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.model.WorkoutStats +import kotlinx.coroutines.flow.Flow + +/** + * Read/query concerns for statistics: computing and caching derived stats. + */ +interface StatsQueryRepository { + suspend fun calculateStats(): WorkoutStats + + suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats + + fun getRecentPRs(limit: Int = 10): Flow> + + suspend fun getPRsWithDetails(limit: Int = 10): List + + suspend fun getAllPRsWithDetails(): List + + suspend fun getMuscleGroupProgress(weeks: Int = 4): List + + suspend fun calculateVolumeTrend(weeks: Int = 4): VolumeTrend + + /** Calendar month vs previous month stats for History comparison UI. */ + suspend fun getPeriodComparison(): PeriodComparison? + + suspend fun getWeeklyVolumeData(weeks: Int = 4): List + + suspend fun getDurationTrend(weeks: Int = 6): List + + suspend fun getWeeklyGoalProgress(target: Int = 5): WeeklyGoalProgress + + suspend fun getWeekCalendarData(): List + + suspend fun getPlannedWorkoutsForDay(timestamp: Long): List + + suspend fun getVolumeBalance(weeks: Int = 4): VolumeBalance + + suspend fun getWeeklyProgress(weeks: Int = 4): List + + suspend fun getMemberSinceDate(): Long? + + suspend fun calculateAccountAgeDays(): Int + + suspend fun getProfileInfo(hasApiKey: Boolean, remoteWorkoutCount: Int? = null): ProfileInfo + + suspend fun getConsistencyData(days: Int = 90): List + + suspend fun getMuscleRecoveryStatus(): Map +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsQueryRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsQueryRepositoryImpl.kt new file mode 100644 index 0000000..8136819 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsQueryRepositoryImpl.kt @@ -0,0 +1,540 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.core.dispatchers.DispatcherProvider +import com.jdluu.flexinsight.data.cache.CacheKeys +import com.jdluu.flexinsight.data.cache.CacheStrategy +import com.jdluu.flexinsight.data.cache.CacheTTL +import com.jdluu.flexinsight.data.local.dao.ExerciseDao +import com.jdluu.flexinsight.data.local.dao.SetDao +import com.jdluu.flexinsight.data.local.dao.WorkoutDao +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.MuscleGroup +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.PeriodComparison +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.data.model.PlannedWorkout +import com.jdluu.flexinsight.data.model.ProfileInfo +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.SingleWorkoutStats +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.VolumeTrend +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.WeeklyVolumeData +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.model.WorkoutStats +import com.jdluu.flexinsight.domain.calc.DurationCalculator +import com.jdluu.flexinsight.domain.calc.RecoveryScoreCalculator +import com.jdluu.flexinsight.domain.calc.StreakCalculator +import com.jdluu.flexinsight.domain.calc.TrainingLoadCalculator +import com.jdluu.flexinsight.domain.calc.VolumeCalculator +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +/** + * Read/query implementation for statistics. + * Optimized to avoid N+1 query problems by using batch operations. + * Uses java.time, StatsCalculator, and CacheStrategy. + */ +class StatsQueryRepositoryImpl( + private val workoutDao: WorkoutDao, + private val exerciseDao: ExerciseDao, + private val setDao: SetDao, + private val exerciseRepository: ExerciseRepository, + private val dispatcherProvider: DispatcherProvider, + private val cacheStrategy: CacheStrategy +) : StatsQueryRepository { + /** + * Calculate workout statistics with caching + */ + override suspend fun calculateStats(): WorkoutStats { + return cacheStrategy.getOrFetch(CacheKeys.WORKOUT_STATS, CacheTTL.STATS) { + computeWorkoutStats() + } + } + + /** + * Calculate statistics for a single workout + */ + override suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats = withContext(dispatcherProvider.default) { + val exercises = exerciseDao.getExercisesByWorkoutId(workout.id) + val exerciseIds = exercises.map { it.id } + val allSets = exerciseIds.flatMap { exerciseId -> + setDao.getSetsByExerciseId(exerciseId) + } + + val totalSets = allSets.size + val totalVolume = VolumeCalculator.totalSetVolume(allSets) + + val durationMinutes = DurationCalculator.workoutDurationMinutes(workout) + + SingleWorkoutStats( + durationMinutes = durationMinutes, + totalSets = totalSets, + totalVolume = totalVolume + ) + } + + /** + * Get recent PRs + */ + override fun getRecentPRs(limit: Int): Flow> { + return setDao.getRecentPRsFlow(limit) + } + + /** + * Get PRs with exercise and workout details (optimized) + */ + override suspend fun getPRsWithDetails(limit: Int): List { + return cacheStrategy.getOrFetch("${CacheKeys.PRS_WITH_DETAILS}_$limit", CacheTTL.PRS) { + computePRDetails(limit) + } + } + + /** + * Get all PRs with details (limit 100) + */ + override suspend fun getAllPRsWithDetails(): List { + return getPRsWithDetails(limit = 100) + } + + /** + * Get muscle group progress for the last N weeks (optimized) + */ + override suspend fun getMuscleGroupProgress(weeks: Int): List { + return cacheStrategy.getOrFetch("${CacheKeys.MUSCLE_GROUP_PROGRESS}$weeks", CacheTTL.PROGRESS) { + computeMuscleGroupProgress(weeks) + } + } + + override suspend fun getPeriodComparison(): PeriodComparison? = withContext(dispatcherProvider.default) { + val zone = ZoneId.systemDefault() + val today = LocalDate.now(zone) + val currentStart = today.withDayOfMonth(1).atStartOfDay(zone).toInstant().toEpochMilli() + val currentEnd = today.plusMonths(1).withDayOfMonth(1).atStartOfDay(zone).toInstant().toEpochMilli() + val previousStart = today.minusMonths(1).withDayOfMonth(1).atStartOfDay(zone).toInstant().toEpochMilli() + + val currentWorkouts = workoutDao.getWorkoutsByDateRangeFlow(currentStart, currentEnd).first() + .filter { !it.isDeleted } + val previousWorkouts = workoutDao.getWorkoutsByDateRangeFlow(previousStart, currentStart).first() + .filter { !it.isDeleted } + + if (currentWorkouts.isEmpty() && previousWorkouts.isEmpty()) return@withContext null + + val currentVolume = calculateTotalVolumeForWorkouts(currentWorkouts) + val previousVolume = calculateTotalVolumeForWorkouts(previousWorkouts) + val currentMonthName = today.month.name.lowercase().replaceFirstChar { it.uppercase() } + val prevMonthName = today.minusMonths(1).month.name.lowercase().replaceFirstChar { it.uppercase() } + + PeriodComparison( + currentPeriodLabel = currentMonthName, + previousPeriodLabel = prevMonthName, + totalVolumeCurrent = currentVolume, + totalVolumePrevious = previousVolume, + totalWorkoutsCurrent = currentWorkouts.size, + totalWorkoutsPrevious = previousWorkouts.size, + avgDurationCurrent = DurationCalculator.averageDurationMinutes(currentWorkouts), + avgDurationPrevious = DurationCalculator.averageDurationMinutes(previousWorkouts) + ) + } + + /** + * Calculate volume trend comparing current period to previous period + */ + override suspend fun calculateVolumeTrend(weeks: Int): VolumeTrend { + return cacheStrategy.getOrFetch("${CacheKeys.VOLUME_TREND}_$weeks", CacheTTL.PROGRESS) { + val now = Instant.now() + val currentPeriodEnd = now.toEpochMilli() + val currentPeriodStart = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() + val previousPeriodStart = now.minus(weeks.toLong() * 14, ChronoUnit.DAYS).toEpochMilli() + + val currentWorkouts = workoutDao.getWorkoutsByDateRangeFlow(currentPeriodStart, currentPeriodEnd).first() + val previousWorkouts = workoutDao.getWorkoutsByDateRangeFlow(previousPeriodStart, currentPeriodStart).first() + + val currentVolume = calculateTotalVolumeForWorkouts(currentWorkouts) + val previousVolume = calculateTotalVolumeForWorkouts(previousWorkouts) + + val percentageChange = VolumeCalculator.changePercent(currentVolume, previousVolume) + + VolumeTrend( + currentVolume = currentVolume, + previousVolume = previousVolume, + percentageChange = percentageChange + ) + } + } + + /** + * Get weekly volume data for chart display + */ + override suspend fun getWeeklyVolumeData(weeks: Int): List = withContext(dispatcherProvider.default) { + val weeklyProgress = getWeeklyProgress(weeks) + weeklyProgress.mapIndexed { index, progress -> + WeeklyVolumeData( + weekLabel = "W${index + 1}", + volume = progress.totalVolume + ) + } + } + + /** + * Get duration trend grouped by day of week + */ + override suspend fun getDurationTrend(weeks: Int): List { + return cacheStrategy.getOrFetch("${CacheKeys.DURATION_TREND}$weeks", CacheTTL.PROGRESS) { + val now = Instant.now() + val endDate = now.toEpochMilli() + val startDate = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() + + val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, endDate).first() + + DurationCalculator.durationTrend(workouts, startDate, endDate) + } + } + + /** + * Get weekly goal progress + */ + override suspend fun getWeeklyGoalProgress(target: Int): WeeklyGoalProgress = withContext(dispatcherProvider.default) { + val now = LocalDate.now() + val weekStart = now.with(java.time.DayOfWeek.MONDAY).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val weekEnd = now.with(java.time.DayOfWeek.SUNDAY).atTime(java.time.LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + val workouts = workoutDao.getWorkoutsByDateRangeFlow(weekStart, weekEnd).first() + val completed = workouts.size + + val status = TrainingLoadCalculator.goalStatus(completed, target) + + WeeklyGoalProgress( + completed = completed, + target = target, + status = status + ) + } + + /** + * Get week calendar data (Monday to Sunday) + */ + override suspend fun getWeekCalendarData(): List = withContext(dispatcherProvider.default) { + val now = LocalDate.now() + val weekStart = now.with(java.time.DayOfWeek.MONDAY) + val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + val days = mutableListOf() + + val weekStartTimestamp = weekStart.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val weekEndTimestamp = weekStart.plusDays(6).atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + val weekWorkouts = workoutDao.getWorkoutsByDateRangeFlow(weekStartTimestamp, weekEndTimestamp).first() + + for (i in 0..6) { + val date = weekStart.plusDays(i.toLong()) + val dayStart = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val dayEnd = date.atTime(java.time.LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + val workouts = weekWorkouts.filter { it.startTime in dayStart..dayEnd } + days.add( + DayInfo( + name = dayNames[i], + date = date.dayOfMonth, + timestamp = dayStart, + hasWorkout = workouts.isNotEmpty(), + isCompleted = workouts.any { it.endTime != null }, + workoutCount = workouts.size + ) + ) + } + days + } + + /** + * Get planned workouts for a specific day + */ + override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List = withContext(dispatcherProvider.default) { + val dayStart = DurationCalculator.startOfDay(timestamp) + val dayEnd = DurationCalculator.endOfDay(timestamp) + + val workouts = workoutDao.getWorkoutsByDateRangeFlow(dayStart, dayEnd).first() + val workoutIds = workouts.map { it.id } + val allExercises = workoutIds.flatMap { workoutId -> + exerciseDao.getExercisesByWorkoutId(workoutId) + } + val exerciseIds = allExercises.map { it.id } + val allSets = exerciseIds.flatMap { exerciseId -> + setDao.getSetsByExerciseId(exerciseId) + } + + val exercisesByWorkout = allExercises.groupBy { it.workoutId } + val setsByExercise = allSets.groupBy { it.exerciseId } + + workouts.map { workout -> + val exercises = exercisesByWorkout[workout.id] ?: emptyList() + val totalVolume = exercises.sumOf { exercise -> + VolumeCalculator.totalSetVolume(setsByExercise[exercise.id] ?: emptyList()) + } + PlannedWorkout( + id = workout.id, + name = workout.name ?: "Workout", + duration = workout.endTime?.let { DurationCalculator.workoutDurationMinutes(workout) }, + intensity = VolumeCalculator.absoluteIntensity(totalVolume), + isCompleted = workout.endTime != null, + routineId = workout.routineId, + exerciseCount = exercises.size + ) + } + } + + /** + * Get volume balance across Push, Pull, Legs, Cardio + */ + override suspend fun getVolumeBalance(weeks: Int): VolumeBalance = withContext(dispatcherProvider.default) { + val muscleGroupProgress = getMuscleGroupProgress(weeks) + VolumeCalculator.volumeBalance(muscleGroupProgress) + } + + /** + * Get weekly progress + */ + override suspend fun getWeeklyProgress(weeks: Int): List { + return cacheStrategy.getOrFetch("${CacheKeys.WEEKLY_PROGRESS}$weeks", CacheTTL.PROGRESS) { + computeWeeklyProgress(weeks) + } + } + + /** + * Get member since date (timestamp of first workout) + */ + override suspend fun getMemberSinceDate(): Long? { + val workouts = workoutDao.getAllWorkoutsFlow().first() + return workouts.minOfOrNull { it.startTime } + } + + /** + * Calculate account age in days + */ + override suspend fun calculateAccountAgeDays(): Int { + val memberSince = getMemberSinceDate() ?: return 0 + return DurationCalculator.accountAgeDays(memberSince, System.currentTimeMillis()) + } + + /** + * Get profile information + */ + override suspend fun getProfileInfo(hasApiKey: Boolean, remoteWorkoutCount: Int?): ProfileInfo = withContext(dispatcherProvider.default) { + val workouts = workoutDao.getAllWorkoutsFlow().first() + val localCount = workouts.size + + val totalWorkouts = if (remoteWorkoutCount != null) { + java.lang.Math.max(remoteWorkoutCount, localCount) + } else { + localCount + } + val memberSince = getMemberSinceDate() + val accountAgeDays = calculateAccountAgeDays() + + ProfileInfo( + displayName = null, + memberSince = memberSince, + isProMember = hasApiKey, + totalWorkouts = totalWorkouts, + accountAgeDays = accountAgeDays + ) + } + + // Helper functions + + private suspend fun calculateTotalVolumeForWorkouts(workouts: List): Double { + if (workouts.isEmpty()) return 0.0 + + val workoutIds = workouts.map { it.id } + val allExercises = workoutIds.flatMap { workoutId -> + exerciseDao.getExercisesByWorkoutId(workoutId) + } + val exerciseIds = allExercises.map { it.id } + val allSets = exerciseIds.flatMap { exerciseId -> + setDao.getSetsByExerciseId(exerciseId) + } + + return VolumeCalculator.totalVolume(workouts, allExercises, allSets) + } + + /** + * Get persistence data for the last N days (Consistency Heatmap) + */ + override suspend fun getConsistencyData(days: Int): List = withContext(dispatcherProvider.default) { + val now = LocalDate.now() + val startDate = now.minusDays((days - 1).toLong()) + val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + val resultDays = ArrayList(days) + + val allWorkouts = workoutDao.getAllWorkoutsFlow().first() + val workoutsByDate = allWorkouts.groupBy { + Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() + } + + for (i in 0 until days) { + val date = startDate.plusDays(i.toLong()) + val dayStart = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + val workouts = workoutsByDate[date] ?: emptyList() + val dayOfWeek = date.dayOfWeek.value + + resultDays.add( + DayInfo( + name = dayNames[dayOfWeek - 1], + date = date.dayOfMonth, + timestamp = dayStart, + hasWorkout = workouts.isNotEmpty(), + isCompleted = workouts.any { it.endTime != null }, + workoutCount = workouts.size + ) + ) + } + resultDays + } + + override suspend fun getMuscleRecoveryStatus(): Map { + return computeMuscleRecovery() + } + + private suspend fun computeWorkoutStats(): WorkoutStats { + val workoutsWithDetails = workoutDao.getAllWorkoutsWithDetailsFlow().first() + if (workoutsWithDetails.isEmpty()) { + return WorkoutStats( + totalWorkouts = 0, + totalVolume = 0.0, + averageVolume = 0.0, + totalSets = 0, + totalDuration = 0L, + averageDuration = 0L, + currentStreak = 0, + longestStreak = 0, + bestWeekVolume = 0.0, + bestWeekDate = null + ) + } + val workouts = workoutsWithDetails.map { it.workout }.filter { !it.isDeleted } + val allExercises = workoutsWithDetails.flatMap { wd -> wd.exercises.map { it.exercise } } + val allSets = workoutsWithDetails.flatMap { wd -> wd.exercises.flatMap { it.sets } } + val totalWorkouts = workouts.size + val totalVolume = VolumeCalculator.totalVolume(workouts, allExercises, allSets) + val averageVolume = if (totalWorkouts > 0) totalVolume / totalWorkouts else 0.0 + val totalSets = allSets.size + val totalDuration = DurationCalculator.totalDuration(workouts) + val averageDuration = if (totalWorkouts > 0) totalDuration / totalWorkouts else 0L + val currentStreak = StreakCalculator.currentStreak(workouts) + val longestStreak = StreakCalculator.longestStreak(workouts) + val weeklyProgress = computeWeeklyProgress(4) + val bestWeek = weeklyProgress.maxByOrNull { it.totalVolume } + return WorkoutStats( + totalWorkouts = totalWorkouts, + totalVolume = totalVolume, + averageVolume = averageVolume, + totalSets = totalSets, + totalDuration = totalDuration, + averageDuration = averageDuration, + currentStreak = currentStreak, + longestStreak = longestStreak, + bestWeekVolume = bestWeek?.totalVolume ?: 0.0, + bestWeekDate = bestWeek?.weekStartDate + ) + } + + private suspend fun computeWeeklyProgress(weeks: Int): List { + val now = Instant.now() + val endDate = now.toEpochMilli() + val startDate = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() + val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, endDate).first().filter { !it.isDeleted } + val workoutIds = workouts.map { it.id } + val allExercises = workoutIds.flatMap { exerciseDao.getExercisesByWorkoutId(it) } + val allSets = allExercises.map { it.id }.flatMap { setDao.getSetsByExerciseId(it) } + val weekFields = java.time.temporal.WeekFields.of(java.util.Locale.getDefault()) + return workouts.groupBy { workout -> + Instant.ofEpochMilli(workout.startTime) + .atZone(ZoneId.systemDefault()) + .get(weekFields.weekOfWeekBasedYear()) + }.map { (_, weekWorkouts) -> + val weekStart = weekWorkouts.minOfOrNull { it.startTime } ?: 0L + val totalVolume = VolumeCalculator.totalVolume(weekWorkouts, allExercises, allSets) + WeeklyProgress( + weekStartDate = weekStart, + totalVolume = totalVolume, + workoutCount = weekWorkouts.size, + averageVolume = if (weekWorkouts.isNotEmpty()) totalVolume / weekWorkouts.size else 0.0 + ) + } + } + + private suspend fun computePRDetails(limit: Int): List { + val prSets = setDao.getRecentPRsFlow(limit).first() + if (prSets.isEmpty()) return emptyList() + val exerciseIds = prSets.map { it.exerciseId }.distinct() + val exercises = exerciseIds.mapNotNull { exerciseDao.getExerciseById(it) } + val workouts = exercises.map { it.workoutId }.distinct().associateWith { workoutDao.getWorkoutById(it) } + return prSets.mapNotNull { set -> + val exercise = exercises.find { it.id == set.exerciseId } ?: return@mapNotNull null + val workout = workouts[exercise.workoutId] ?: return@mapNotNull null + val weight = set.weight ?: return@mapNotNull null + PRDetails( + exerciseName = exercise.name, + date = workout.startTime, + muscleGroup = exerciseRepository.getMuscleGroupForExercise(exercise) ?: "Unknown", + weight = weight, + workoutId = workout.id, + setId = set.id + ) + } + } + + private suspend fun computeMuscleGroupProgress(weeks: Int): List { + val now = Instant.now() + val startDate = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() + val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, now.toEpochMilli()).first() + .filter { !it.isDeleted } + if (workouts.isEmpty()) return emptyList() + val workoutIds = workouts.map { it.id } + val allExercises = workoutIds.flatMap { exerciseDao.getExercisesByWorkoutId(it) } + val allSets = allExercises.associate { it.id to setDao.getSetsByExerciseId(it.id) } + val muscleGroupData = mutableMapOf>() + allExercises.forEach { exercise -> + val muscleGroup = exerciseRepository.getMuscleGroupForExercise(exercise) ?: return@forEach + val sets = allSets[exercise.id] ?: emptyList() + val exerciseVolume = VolumeCalculator.totalSetVolume(sets) + val current = muscleGroupData[muscleGroup] ?: (0.0 to 0) + muscleGroupData[muscleGroup] = (current.first + exerciseVolume) to (current.second + sets.size) + } + val totalVolume = muscleGroupData.values.sumOf { it.first } + val averageVolume = if (muscleGroupData.isNotEmpty()) totalVolume / muscleGroupData.size else 0.0 + return muscleGroupData.map { (muscleGroup, data) -> + val (volume, sets) = data + MuscleGroupProgress( + muscleGroup = muscleGroup, + volume = volume, + sets = sets, + intensity = VolumeCalculator.relativeIntensity(volume, averageVolume) + ) + }.sortedByDescending { it.volume } + } + + private suspend fun computeMuscleRecovery(): Map = withContext(dispatcherProvider.io) { + val now = System.currentTimeMillis() + val sevenDaysAgo = now - (7 * 24 * 60 * 60 * 1000L) + val workouts = workoutDao.getWorkoutsSinceFlow(sevenDaysAgo).first().filter { !it.isDeleted } + val lastTrainedMap = mutableMapOf() + workouts.sortedByDescending { it.startTime }.forEach { workout -> + exerciseDao.getExercisesByWorkoutId(workout.id) + .mapNotNull { MuscleGroup.fromString(exerciseRepository.getMuscleGroupForExercise(it)) } + .distinct() + .forEach { group -> + if (!lastTrainedMap.containsKey(group)) { + lastTrainedMap[group] = workout.startTime + } + } + } + RecoveryScoreCalculator.muscleRecoveryStatus(now, lastTrainedMap) + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsRepositoryImpl.kt index 62dd59d..2d15338 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsRepositoryImpl.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/StatsRepositoryImpl.kt @@ -1,539 +1,114 @@ package com.jdluu.flexinsight.data.repository -import com.jdluu.flexinsight.data.cache.CacheKeys -import com.jdluu.flexinsight.data.cache.CacheManager -import com.jdluu.flexinsight.data.cache.CacheStrategy -import com.jdluu.flexinsight.data.cache.CacheTTL -import com.jdluu.flexinsight.data.local.dao.ExerciseDao -import com.jdluu.flexinsight.data.local.dao.SetDao -import com.jdluu.flexinsight.data.local.dao.WorkoutDao -import com.jdluu.flexinsight.data.model.* +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.DayInfo +import com.jdluu.flexinsight.data.model.MuscleGroup +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.PeriodComparison +import com.jdluu.flexinsight.data.model.PRDetails +import com.jdluu.flexinsight.data.model.PlannedWorkout +import com.jdluu.flexinsight.data.model.ProfileInfo +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.SingleWorkoutStats +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.VolumeTrend +import com.jdluu.flexinsight.data.model.WeeklyGoalProgress +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.WeeklyVolumeData +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.model.WorkoutStats import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import java.time.Instant -import java.time.ZoneId -import java.time.temporal.ChronoUnit -import java.time.LocalDate -import java.time.LocalTime -import com.jdluu.flexinsight.domain.calc.DurationCalculator -import com.jdluu.flexinsight.domain.calc.RecoveryScoreCalculator -import com.jdluu.flexinsight.domain.calc.StreakCalculator -import com.jdluu.flexinsight.domain.calc.TrainingLoadCalculator -import com.jdluu.flexinsight.domain.calc.VolumeCalculator -import com.jdluu.flexinsight.core.dispatchers.DispatcherProvider -import kotlinx.coroutines.withContext -import javax.inject.Inject /** - * Repository for statistics calculations. - * Optimized to avoid N+1 query problems by using batch operations. - * Refactored to use java.time, StatsCalculator, and CacheStrategy. + * Repository for statistics and data analysis. + * Facade over [StatsQueryRepository] (reads/computations) and [StatsMutationRepository] + * (cache invalidation), kept for backward compatibility with existing ViewModels and use cases. */ -class StatsRepositoryImpl @Inject constructor( - private val workoutDao: WorkoutDao, - private val exerciseDao: ExerciseDao, - private val setDao: SetDao, - private val exerciseRepository: ExerciseRepository, - private val cacheManager: CacheManager, - private val dispatcherProvider: DispatcherProvider, - private val cacheStrategy: CacheStrategy +class StatsRepositoryImpl( + private val queryRepository: StatsQueryRepository, + private val mutationRepository: StatsMutationRepository ) : StatsRepository { - /** - * Calculate workout statistics with caching - */ - override suspend fun calculateStats(): WorkoutStats { - return cacheStrategy.getOrFetch(CacheKeys.WORKOUT_STATS, CacheTTL.STATS) { - computeWorkoutStats() - } + override fun invalidateStatsCache() { + mutationRepository.invalidateStatsCache() } - /** - * Calculate statistics for a single workout - */ - override suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats = withContext(dispatcherProvider.default) { - val exercises = exerciseDao.getExercisesByWorkoutId(workout.id) - val exerciseIds = exercises.map { it.id } - val allSets = exerciseIds.flatMap { exerciseId -> - setDao.getSetsByExerciseId(exerciseId) - } - - val totalSets = allSets.size - val totalVolume = VolumeCalculator.totalSetVolume(allSets) - - val durationMinutes = DurationCalculator.workoutDurationMinutes(workout) + override suspend fun calculateStats(): WorkoutStats { + return queryRepository.calculateStats() + } - SingleWorkoutStats( - durationMinutes = durationMinutes, - totalSets = totalSets, - totalVolume = totalVolume - ) + override suspend fun calculateWorkoutStats(workout: Workout): SingleWorkoutStats { + return queryRepository.calculateWorkoutStats(workout) } - /** - * Get recent PRs - */ - override fun getRecentPRs(limit: Int): Flow> { - return setDao.getRecentPRsFlow(limit) + override fun getRecentPRs(limit: Int): Flow> { + return queryRepository.getRecentPRs(limit) } - /** - * Get PRs with exercise and workout details (optimized) - */ override suspend fun getPRsWithDetails(limit: Int): List { - return cacheStrategy.getOrFetch("${CacheKeys.PRS_WITH_DETAILS}_$limit", CacheTTL.PRS) { - computePRDetails(limit) - } + return queryRepository.getPRsWithDetails(limit) } - /** - * Get all PRs with details (limit 100) - */ override suspend fun getAllPRsWithDetails(): List { - return getPRsWithDetails(limit = 100) + return queryRepository.getAllPRsWithDetails() } - /** - * Get muscle group progress for the last N weeks (optimized) - */ override suspend fun getMuscleGroupProgress(weeks: Int): List { - return cacheStrategy.getOrFetch("${CacheKeys.MUSCLE_GROUP_PROGRESS}$weeks", CacheTTL.PROGRESS) { - computeMuscleGroupProgress(weeks) - } - } - - override suspend fun getPeriodComparison(): PeriodComparison? = withContext(dispatcherProvider.default) { - val zone = ZoneId.systemDefault() - val today = LocalDate.now(zone) - val currentStart = today.withDayOfMonth(1).atStartOfDay(zone).toInstant().toEpochMilli() - val currentEnd = today.plusMonths(1).withDayOfMonth(1).atStartOfDay(zone).toInstant().toEpochMilli() - val previousStart = today.minusMonths(1).withDayOfMonth(1).atStartOfDay(zone).toInstant().toEpochMilli() - - val currentWorkouts = workoutDao.getWorkoutsByDateRangeFlow(currentStart, currentEnd).first() - .filter { !it.isDeleted } - val previousWorkouts = workoutDao.getWorkoutsByDateRangeFlow(previousStart, currentStart).first() - .filter { !it.isDeleted } - - if (currentWorkouts.isEmpty() && previousWorkouts.isEmpty()) return@withContext null - - val currentVolume = calculateTotalVolumeForWorkouts(currentWorkouts) - val previousVolume = calculateTotalVolumeForWorkouts(previousWorkouts) - val currentMonthName = today.month.name.lowercase().replaceFirstChar { it.uppercase() } - val prevMonthName = today.minusMonths(1).month.name.lowercase().replaceFirstChar { it.uppercase() } - - PeriodComparison( - currentPeriodLabel = currentMonthName, - previousPeriodLabel = prevMonthName, - totalVolumeCurrent = currentVolume, - totalVolumePrevious = previousVolume, - totalWorkoutsCurrent = currentWorkouts.size, - totalWorkoutsPrevious = previousWorkouts.size, - avgDurationCurrent = DurationCalculator.averageDurationMinutes(currentWorkouts), - avgDurationPrevious = DurationCalculator.averageDurationMinutes(previousWorkouts) - ) + return queryRepository.getMuscleGroupProgress(weeks) } - /** - * Calculate volume trend comparing current period to previous period - */ override suspend fun calculateVolumeTrend(weeks: Int): VolumeTrend { - return cacheStrategy.getOrFetch("${CacheKeys.VOLUME_TREND}_$weeks", CacheTTL.PROGRESS) { - val now = Instant.now() - val currentPeriodEnd = now.toEpochMilli() - val currentPeriodStart = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() - val previousPeriodStart = now.minus(weeks.toLong() * 14, ChronoUnit.DAYS).toEpochMilli() - - val currentWorkouts = workoutDao.getWorkoutsByDateRangeFlow(currentPeriodStart, currentPeriodEnd).first() - val previousWorkouts = workoutDao.getWorkoutsByDateRangeFlow(previousPeriodStart, currentPeriodStart).first() - - val currentVolume = calculateTotalVolumeForWorkouts(currentWorkouts) - val previousVolume = calculateTotalVolumeForWorkouts(previousWorkouts) - - val percentageChange = VolumeCalculator.changePercent(currentVolume, previousVolume) + return queryRepository.calculateVolumeTrend(weeks) + } - VolumeTrend( - currentVolume = currentVolume, - previousVolume = previousVolume, - percentageChange = percentageChange - ) - } + override suspend fun getPeriodComparison(): PeriodComparison? { + return queryRepository.getPeriodComparison() } - /** - * Get weekly volume data for chart display - */ - override suspend fun getWeeklyVolumeData(weeks: Int): List = withContext(dispatcherProvider.default) { - val weeklyProgress = getWeeklyProgress(weeks) - weeklyProgress.mapIndexed { index, progress -> - WeeklyVolumeData( - weekLabel = "W${index + 1}", - volume = progress.totalVolume - ) - } + override suspend fun getWeeklyVolumeData(weeks: Int): List { + return queryRepository.getWeeklyVolumeData(weeks) } - /** - * Get duration trend grouped by day of week - */ override suspend fun getDurationTrend(weeks: Int): List { - return cacheStrategy.getOrFetch("${CacheKeys.DURATION_TREND}$weeks", CacheTTL.PROGRESS) { - val now = Instant.now() - val endDate = now.toEpochMilli() - val startDate = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() - - val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, endDate).first() - - DurationCalculator.durationTrend(workouts, startDate, endDate) - } + return queryRepository.getDurationTrend(weeks) } - /** - * Get weekly goal progress - */ - override suspend fun getWeeklyGoalProgress(target: Int): WeeklyGoalProgress = withContext(dispatcherProvider.default) { - val now = LocalDate.now() - val weekStart = now.with(java.time.DayOfWeek.MONDAY).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - val weekEnd = now.with(java.time.DayOfWeek.SUNDAY).atTime(java.time.LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - - val workouts = workoutDao.getWorkoutsByDateRangeFlow(weekStart, weekEnd).first() - val completed = workouts.size - - val status = TrainingLoadCalculator.goalStatus(completed, target) - - WeeklyGoalProgress( - completed = completed, - target = target, - status = status - ) + override suspend fun getWeeklyGoalProgress(target: Int): WeeklyGoalProgress { + return queryRepository.getWeeklyGoalProgress(target) } - /** - * Get week calendar data (Monday to Sunday) - */ - override suspend fun getWeekCalendarData(): List = withContext(dispatcherProvider.default) { - val now = LocalDate.now() - val weekStart = now.with(java.time.DayOfWeek.MONDAY) - val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") - val days = mutableListOf() - - val weekStartTimestamp = weekStart.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - val weekEndTimestamp = weekStart.plusDays(6).atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - val weekWorkouts = workoutDao.getWorkoutsByDateRangeFlow(weekStartTimestamp, weekEndTimestamp).first() - - for (i in 0..6) { - val date = weekStart.plusDays(i.toLong()) - val dayStart = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - val dayEnd = date.atTime(java.time.LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - - val workouts = weekWorkouts.filter { it.startTime in dayStart..dayEnd } - days.add( - DayInfo( - name = dayNames[i], - date = date.dayOfMonth, - timestamp = dayStart, - hasWorkout = workouts.isNotEmpty(), - isCompleted = workouts.any { it.endTime != null }, - workoutCount = workouts.size - ) - ) - } - days + override suspend fun getWeekCalendarData(): List { + return queryRepository.getWeekCalendarData() } - /** - * Get planned workouts for a specific day - */ - override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List = withContext(dispatcherProvider.default) { - val dayStart = DurationCalculator.startOfDay(timestamp) - val dayEnd = DurationCalculator.endOfDay(timestamp) - - val workouts = workoutDao.getWorkoutsByDateRangeFlow(dayStart, dayEnd).first() - val workoutIds = workouts.map { it.id } - val allExercises = workoutIds.flatMap { workoutId -> - exerciseDao.getExercisesByWorkoutId(workoutId) - } - val exerciseIds = allExercises.map { it.id } - val allSets = exerciseIds.flatMap { exerciseId -> - setDao.getSetsByExerciseId(exerciseId) - } - - val exercisesByWorkout = allExercises.groupBy { it.workoutId } - val setsByExercise = allSets.groupBy { it.exerciseId } - - workouts.map { workout -> - val exercises = exercisesByWorkout[workout.id] ?: emptyList() - val totalVolume = exercises.sumOf { exercise -> - VolumeCalculator.totalSetVolume(setsByExercise[exercise.id] ?: emptyList()) - } - PlannedWorkout( - id = workout.id, - name = workout.name ?: "Workout", - duration = workout.endTime?.let { DurationCalculator.workoutDurationMinutes(workout) }, - intensity = VolumeCalculator.absoluteIntensity(totalVolume), - isCompleted = workout.endTime != null, - routineId = workout.routineId, - exerciseCount = exercises.size - ) - } + override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List { + return queryRepository.getPlannedWorkoutsForDay(timestamp) } - /** - * Get volume balance across Push, Pull, Legs, Cardio - */ - override suspend fun getVolumeBalance(weeks: Int): VolumeBalance = withContext(dispatcherProvider.default) { - val muscleGroupProgress = getMuscleGroupProgress(weeks) - VolumeCalculator.volumeBalance(muscleGroupProgress) + override suspend fun getVolumeBalance(weeks: Int): VolumeBalance { + return queryRepository.getVolumeBalance(weeks) } - /** - * Get weekly progress - */ override suspend fun getWeeklyProgress(weeks: Int): List { - return cacheStrategy.getOrFetch("${CacheKeys.WEEKLY_PROGRESS}$weeks", CacheTTL.PROGRESS) { - computeWeeklyProgress(weeks) - } + return queryRepository.getWeeklyProgress(weeks) } - /** - * Get member since date (timestamp of first workout) - */ override suspend fun getMemberSinceDate(): Long? { - val workouts = workoutDao.getAllWorkoutsFlow().first() - return workouts.minOfOrNull { it.startTime } + return queryRepository.getMemberSinceDate() } - /** - * Calculate account age in days - */ override suspend fun calculateAccountAgeDays(): Int { - val memberSince = getMemberSinceDate() ?: return 0 - return DurationCalculator.accountAgeDays(memberSince, System.currentTimeMillis()) - } - - /** - * Get profile information - */ - override suspend fun getProfileInfo(hasApiKey: Boolean, remoteWorkoutCount: Int?): ProfileInfo = withContext(dispatcherProvider.default) { - val workouts = workoutDao.getAllWorkoutsFlow().first() - val localCount = workouts.size - - val totalWorkouts = if (remoteWorkoutCount != null) { - java.lang.Math.max(remoteWorkoutCount, localCount) - } else { - localCount - } - val memberSince = getMemberSinceDate() - val accountAgeDays = calculateAccountAgeDays() - - ProfileInfo( - displayName = null, - memberSince = memberSince, - isProMember = hasApiKey, - totalWorkouts = totalWorkouts, - accountAgeDays = accountAgeDays - ) + return queryRepository.calculateAccountAgeDays() } - /** - * Invalidate stats cache - */ - override fun invalidateStatsCache() { - cacheManager.invalidatePrefix(CacheKeys.WORKOUT_STATS) - cacheManager.invalidatePrefix(CacheKeys.PRS_WITH_DETAILS) - cacheManager.invalidatePrefix(CacheKeys.MUSCLE_GROUP_PROGRESS) - cacheManager.invalidatePrefix(CacheKeys.WEEKLY_PROGRESS) - cacheManager.invalidatePrefix(CacheKeys.VOLUME_TREND) - cacheManager.invalidatePrefix(CacheKeys.DURATION_TREND) + override suspend fun getProfileInfo(hasApiKey: Boolean, remoteWorkoutCount: Int?): ProfileInfo { + return queryRepository.getProfileInfo(hasApiKey, remoteWorkoutCount) } - // Helper functions - - private suspend fun calculateTotalVolumeForWorkouts(workouts: List): Double { - if (workouts.isEmpty()) return 0.0 - - val workoutIds = workouts.map { it.id } - val allExercises = workoutIds.flatMap { workoutId -> - exerciseDao.getExercisesByWorkoutId(workoutId) - } - val exerciseIds = allExercises.map { it.id } - val allSets = exerciseIds.flatMap { exerciseId -> - setDao.getSetsByExerciseId(exerciseId) - } - - return VolumeCalculator.totalVolume(workouts, allExercises, allSets) + override suspend fun getConsistencyData(days: Int): List { + return queryRepository.getConsistencyData(days) } - /** - * Get persistence data for the last N days (Consistency Heatmap) - */ - override suspend fun getConsistencyData(days: Int): List = withContext(dispatcherProvider.default) { - val now = LocalDate.now() - val startDate = now.minusDays((days - 1).toLong()) - val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") - val resultDays = ArrayList(days) - - val allWorkouts = workoutDao.getAllWorkoutsFlow().first() - val workoutsByDate = allWorkouts.groupBy { - Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() - } - - for (i in 0 until days) { - val date = startDate.plusDays(i.toLong()) - val dayStart = date.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - val workouts = workoutsByDate[date] ?: emptyList() - val dayOfWeek = date.dayOfWeek.value - - resultDays.add( - DayInfo( - name = dayNames[dayOfWeek - 1], - date = date.dayOfMonth, - timestamp = dayStart, - hasWorkout = workouts.isNotEmpty(), - isCompleted = workouts.any { it.endTime != null }, - workoutCount = workouts.size - ) - ) - } - resultDays - } - override suspend fun getMuscleRecoveryStatus(): Map { - return computeMuscleRecovery() - } - - private suspend fun computeWorkoutStats(): WorkoutStats { - val workoutsWithDetails = workoutDao.getAllWorkoutsWithDetailsFlow().first() - if (workoutsWithDetails.isEmpty()) { - return WorkoutStats( - totalWorkouts = 0, - totalVolume = 0.0, - averageVolume = 0.0, - totalSets = 0, - totalDuration = 0L, - averageDuration = 0L, - currentStreak = 0, - longestStreak = 0, - bestWeekVolume = 0.0, - bestWeekDate = null - ) - } - val workouts = workoutsWithDetails.map { it.workout }.filter { !it.isDeleted } - val allExercises = workoutsWithDetails.flatMap { wd -> wd.exercises.map { it.exercise } } - val allSets = workoutsWithDetails.flatMap { wd -> wd.exercises.flatMap { it.sets } } - val totalWorkouts = workouts.size - val totalVolume = VolumeCalculator.totalVolume(workouts, allExercises, allSets) - val averageVolume = if (totalWorkouts > 0) totalVolume / totalWorkouts else 0.0 - val totalSets = allSets.size - val totalDuration = DurationCalculator.totalDuration(workouts) - val averageDuration = if (totalWorkouts > 0) totalDuration / totalWorkouts else 0L - val currentStreak = StreakCalculator.currentStreak(workouts) - val longestStreak = StreakCalculator.longestStreak(workouts) - val weeklyProgress = computeWeeklyProgress(4) - val bestWeek = weeklyProgress.maxByOrNull { it.totalVolume } - return WorkoutStats( - totalWorkouts = totalWorkouts, - totalVolume = totalVolume, - averageVolume = averageVolume, - totalSets = totalSets, - totalDuration = totalDuration, - averageDuration = averageDuration, - currentStreak = currentStreak, - longestStreak = longestStreak, - bestWeekVolume = bestWeek?.totalVolume ?: 0.0, - bestWeekDate = bestWeek?.weekStartDate - ) - } - - private suspend fun computeWeeklyProgress(weeks: Int): List { - val now = Instant.now() - val endDate = now.toEpochMilli() - val startDate = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() - val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, endDate).first().filter { !it.isDeleted } - val workoutIds = workouts.map { it.id } - val allExercises = workoutIds.flatMap { exerciseDao.getExercisesByWorkoutId(it) } - val allSets = allExercises.map { it.id }.flatMap { setDao.getSetsByExerciseId(it) } - val weekFields = java.time.temporal.WeekFields.of(java.util.Locale.getDefault()) - return workouts.groupBy { workout -> - Instant.ofEpochMilli(workout.startTime) - .atZone(ZoneId.systemDefault()) - .get(weekFields.weekOfWeekBasedYear()) - }.map { (_, weekWorkouts) -> - val weekStart = weekWorkouts.minOfOrNull { it.startTime } ?: 0L - val totalVolume = VolumeCalculator.totalVolume(weekWorkouts, allExercises, allSets) - WeeklyProgress( - weekStartDate = weekStart, - totalVolume = totalVolume, - workoutCount = weekWorkouts.size, - averageVolume = if (weekWorkouts.isNotEmpty()) totalVolume / weekWorkouts.size else 0.0 - ) - } - } - - private suspend fun computePRDetails(limit: Int): List { - val prSets = setDao.getRecentPRsFlow(limit).first() - if (prSets.isEmpty()) return emptyList() - val exerciseIds = prSets.map { it.exerciseId }.distinct() - val exercises = exerciseIds.mapNotNull { exerciseDao.getExerciseById(it) } - val workouts = exercises.map { it.workoutId }.distinct().associateWith { workoutDao.getWorkoutById(it) } - return prSets.mapNotNull { set -> - val exercise = exercises.find { it.id == set.exerciseId } ?: return@mapNotNull null - val workout = workouts[exercise.workoutId] ?: return@mapNotNull null - val weight = set.weight ?: return@mapNotNull null - PRDetails( - exerciseName = exercise.name, - date = workout.startTime, - muscleGroup = exerciseRepository.getMuscleGroupForExercise(exercise) ?: "Unknown", - weight = weight, - workoutId = workout.id, - setId = set.id - ) - } - } - - private suspend fun computeMuscleGroupProgress(weeks: Int): List { - val now = Instant.now() - val startDate = now.minus(weeks.toLong() * 7, ChronoUnit.DAYS).toEpochMilli() - val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, now.toEpochMilli()).first() - .filter { !it.isDeleted } - if (workouts.isEmpty()) return emptyList() - val workoutIds = workouts.map { it.id } - val allExercises = workoutIds.flatMap { exerciseDao.getExercisesByWorkoutId(it) } - val allSets = allExercises.associate { it.id to setDao.getSetsByExerciseId(it.id) } - val muscleGroupData = mutableMapOf>() - allExercises.forEach { exercise -> - val muscleGroup = exerciseRepository.getMuscleGroupForExercise(exercise) ?: return@forEach - val sets = allSets[exercise.id] ?: emptyList() - val exerciseVolume = VolumeCalculator.totalSetVolume(sets) - val current = muscleGroupData[muscleGroup] ?: (0.0 to 0) - muscleGroupData[muscleGroup] = (current.first + exerciseVolume) to (current.second + sets.size) - } - val totalVolume = muscleGroupData.values.sumOf { it.first } - val averageVolume = if (muscleGroupData.isNotEmpty()) totalVolume / muscleGroupData.size else 0.0 - return muscleGroupData.map { (muscleGroup, data) -> - val (volume, sets) = data - MuscleGroupProgress( - muscleGroup = muscleGroup, - volume = volume, - sets = sets, - intensity = VolumeCalculator.relativeIntensity(volume, averageVolume) - ) - }.sortedByDescending { it.volume } - } - - private suspend fun computeMuscleRecovery(): Map = withContext(dispatcherProvider.io) { - val now = System.currentTimeMillis() - val sevenDaysAgo = now - (7 * 24 * 60 * 60 * 1000L) - val workouts = workoutDao.getWorkoutsSinceFlow(sevenDaysAgo).first().filter { !it.isDeleted } - val lastTrainedMap = mutableMapOf() - workouts.sortedByDescending { it.startTime }.forEach { workout -> - exerciseDao.getExercisesByWorkoutId(workout.id) - .mapNotNull { MuscleGroup.fromString(exerciseRepository.getMuscleGroupForExercise(it)) } - .distinct() - .forEach { group -> - if (!lastTrainedMap.containsKey(group)) { - lastTrainedMap[group] = workout.startTime - } - } - } - RecoveryScoreCalculator.muscleRecoveryStatus(now, lastTrainedMap) + return queryRepository.getMuscleRecoveryStatus() } } diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutMutationRepository.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutMutationRepository.kt new file mode 100644 index 0000000..61bb7ba --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutMutationRepository.kt @@ -0,0 +1,21 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.model.WorkoutResponse + +/** + * Mutation concerns for workouts: persisting API payloads, syncing with the remote API, + * and updating local workout state. + * Reads/queries live in [WorkoutQueryRepository]. + */ +interface WorkoutMutationRepository { + fun invalidateApiService() + + suspend fun saveWorkoutWithExercisesAndSets(workoutResponse: WorkoutResponse) + + suspend fun syncWorkouts(): Result + + suspend fun updateWorkoutStatus(workoutId: String, isCompleted: Boolean, endTime: Long?): Result + + suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutMutationRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutMutationRepositoryImpl.kt new file mode 100644 index 0000000..ffbaf5f --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutMutationRepositoryImpl.kt @@ -0,0 +1,428 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.ErrorHandler +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.core.network.NetworkMonitor +import com.jdluu.flexinsight.data.api.FlexApiClient +import com.jdluu.flexinsight.data.api.FlexApiService +import com.jdluu.flexinsight.data.cache.CacheKeys +import com.jdluu.flexinsight.data.cache.CacheManager +import com.jdluu.flexinsight.data.cache.CacheTTL +import com.jdluu.flexinsight.data.local.dao.ExerciseDao +import com.jdluu.flexinsight.data.local.dao.SetDao +import com.jdluu.flexinsight.data.local.dao.WorkoutDao +import com.jdluu.flexinsight.data.mapper.WorkoutMapper +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.WorkoutResponse +import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import com.jdluu.flexinsight.data.preferences.SyncPreferencesManager +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit + +/** + * Mutation/sync implementation for workouts. + * Owns all writes: persisting API payloads, remote sync (full and event-based), + * and local workout state updates. + */ +class WorkoutMutationRepositoryImpl( + private val workoutDao: WorkoutDao, + private val exerciseDao: ExerciseDao, + private val setDao: SetDao, + private val apiKeyManager: ApiKeyManager, + private val networkMonitor: NetworkMonitor, + private val apiClient: FlexApiClient, + private val cacheManager: CacheManager, + private val syncManager: com.jdluu.flexinsight.data.sync.SyncManager, + private val syncPreferencesManager: SyncPreferencesManager +) : WorkoutMutationRepository { + private var apiService: FlexApiService? = null + private var currentApiKey: String? = null + + /** + * Gets API service, creating it if needed + */ + private suspend fun getApiService(): Result { + val apiKey = apiKeyManager.getApiKey() ?: return Result.error( + ApiError.AuthError.InvalidApiKey + ) + + // Recreate service if API key has changed + if (apiService == null || currentApiKey != apiKey) { + apiService = apiClient.createApiService(apiKey) + currentApiKey = apiKey + } + + val service = apiService ?: return Result.error(ApiError.Unknown("API service not initialized")) + return Result.success(service) + } + + /** + * Invalidates the API service (useful when API key is updated) + */ + override fun invalidateApiService() { + apiService = null + currentApiKey = null + } + + /** + * Saves a workout with its exercises and sets to the database atomically + */ + override suspend fun saveWorkoutWithExercisesAndSets(workoutResponse: WorkoutResponse) { + val workout = WorkoutMapper.toWorkout(workoutResponse) + val exercises = mutableListOf() + val sets = mutableListOf() + + workoutResponse.exercises?.forEach { exerciseResponse -> + val exercise = WorkoutMapper.toExercise(exerciseResponse, workoutResponse.id) + exercises.add(exercise) + + exerciseResponse.sets?.forEach { setResponse -> + sets.add(WorkoutMapper.toSet(setResponse, exercise.id)) + } + } + + workoutDao.insertWorkoutWithDetails( + workout = workout, + exercises = exercises, + sets = sets, + exerciseDao = exerciseDao, + setDao = setDao + ) + } + + /** + * Sync workouts from API + * Tries events endpoint first for incremental sync, falls back to regular workouts endpoint + */ + override suspend fun syncWorkouts(): Result { + val apiServiceResult = getApiService() + if (apiServiceResult is Result.Error) { + return Result.error(apiServiceResult.error) + } + + // Check network before syncing + if (!networkMonitor.hasNetworkConnection()) { + return Result.error(ApiError.NetworkError.NoConnection) + } + + // Check if we have any workouts in the database (for incremental sync) + val mostRecentSynced = workoutDao.getMostRecentSyncedTimestamp() + val localCount = workoutDao.getWorkoutCount() + + // Check remote count to see if we are missing data (backfill needed) + // This prevents the issue where we have a recent timestamp but are missing older workouts + var shouldForceFullSync = false + try { + val remoteCountResult = fetchRemoteWorkoutCount() + if (remoteCountResult is Result.Success) { + val remoteCount = remoteCountResult.data + // If we have significantly fewer workouts than remote, force full sync + if (localCount < remoteCount) { + shouldForceFullSync = true + com.jdluu.flexinsight.core.logger.AppLogger.d("Force full sync: Local $localCount < Remote $remoteCount") + } + } + } catch (e: Exception) { + // Ignore error, proceed with standard logic + com.jdluu.flexinsight.core.logger.AppLogger.e("Failed to check remote count during sync: ${e.message}") + } + + val isIncrementalSync = mostRecentSynced != null && !shouldForceFullSync + + // Try events endpoint first for incremental sync + if (isIncrementalSync) { + val eventsResult = syncWorkoutsFromEvents() + if (eventsResult is Result.Success) { + return eventsResult + } + // If events endpoint fails (404, etc.), fall through to regular sync + } + + // Fallback to regular workouts endpoint + val apiService = (apiServiceResult as Result.Success).data + + return try { + + var page = 1 + var hasMore = true + var allWorkoutsExist = false + + while (hasMore && !allWorkoutsExist) { + val response = apiService.getWorkouts(page, 10) + + if (response.isSuccessful) { + val paginatedResponse = response.body() ?: return Result.error( + ApiError.Unknown("Empty response body") + ) + val workoutsList = paginatedResponse.workouts + + if (workoutsList == null || workoutsList.isEmpty()) { + hasMore = false + continue + } + + val workoutIds = workoutsList.map { it.id } + val existingIds = if (workoutIds.isEmpty()) { + emptySet() + } else { + workoutDao.getExistingWorkoutIds(workoutIds).toSet() + } + + if (isIncrementalSync && workoutIds.all { it in existingIds }) { + allWorkoutsExist = true + break + } + + val idsToFetch = workoutIds.filter { it !in existingIds } + fetchAndSaveWorkoutDetails(apiService, idsToFetch) + + // Check if there are more pages + hasMore = page < paginatedResponse.pageCount + page++ + } else { + val error = if (response.code() == 401 || response.code() == 403) { + ApiError.AuthError.InvalidApiKey + } else { + ErrorHandler.handleHttpException(retrofit2.HttpException(response)) + } + + if (error is ApiError.AuthError) { + invalidateApiService() + } + + return Result.error(error) + } + } + + Result.success(Unit) + } catch (e: Exception) { + val error = ErrorHandler.handleError(e) + Result.error(error) + } + } + + /** + * Converts timestamp (milliseconds) to ISO 8601 format for API + */ + private fun timestampToIso8601(timestampMillis: Long): String { + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) + dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC") + return dateFormat.format(java.util.Date(timestampMillis)) + } + + /** + * Sync workouts from events endpoint + * Uses incremental sync with 'since' parameter based on lastSynced timestamp + */ + private suspend fun syncWorkoutsFromEvents(): Result { + val apiServiceResult = getApiService() + if (apiServiceResult is Result.Error) { + return Result.error(apiServiceResult.error) + } + + // Check network before syncing + if (!networkMonitor.hasNetworkConnection()) { + return Result.error(ApiError.NetworkError.NoConnection) + } + + val apiService = (apiServiceResult as Result.Success).data + + return try { + // Get most recent synced timestamp + val mostRecentSynced = workoutDao.getMostRecentSyncedTimestamp() + val sinceParam = mostRecentSynced?.let { timestampToIso8601(it) } + + // If no previous sync, return error to fallback to regular sync + if (sinceParam == null) { + return Result.error(ApiError.Unknown("No previous sync timestamp, use regular sync")) + } + + var page = 1 + var hasMore = true + + while (hasMore) { + val response = apiService.getWorkoutEvents(page = page, pageSize = 10, since = sinceParam) + + if (response.isSuccessful) { + val eventsResponse = response.body() ?: return Result.error( + ApiError.Unknown("Empty response body") + ) + val events = eventsResponse.events + + if (events == null || events.isEmpty()) { + hasMore = false + continue + } + + val idsToRefresh = mutableListOf() + events.forEach { event -> + val workoutId = event.workoutId ?: return@forEach + when (event.type) { + "created", "updated" -> idsToRefresh.add(workoutId) + "deleted" -> { + workoutDao.softDeleteWorkoutById(workoutId) + syncPreferencesManager.recordDeletedWorkout() + } + } + } + fetchAndSaveWorkoutDetails(apiService, idsToRefresh.distinct()) + + // Check if there are more pages + hasMore = page < eventsResponse.pageCount + page++ + } else { + val error = if (response.code() == 401 || response.code() == 403) { + ApiError.AuthError.InvalidApiKey + } else { + ErrorHandler.handleHttpException(retrofit2.HttpException(response)) + } + + if (error is ApiError.AuthError) { + invalidateApiService() + } + + return Result.error(error) + } + } + + Result.success(Unit) + } catch (e: Exception) { + val error = ErrorHandler.handleError(e) + Result.error(error) + } + } + + /** + * Update workout status (completed/incomplete) + */ + override suspend fun updateWorkoutStatus(workoutId: String, isCompleted: Boolean, endTime: Long?): Result { + val workout = workoutDao.getWorkoutById(workoutId) ?: return Result.error(ApiError.Unknown("Workout not found")) + + val updatedWorkout = workout.copy( + endTime = if (isCompleted) (endTime ?: System.currentTimeMillis()) else null, + needsSync = true + ) + + workoutDao.updateWorkout(updatedWorkout) + + // Queue for background sync immediately + syncManager.syncNow() + + return Result.success(Unit) + } + + /** + * Reschedule a workout + */ + override suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result { + val workout = workoutDao.getWorkoutById(workoutId) ?: return Result.error(ApiError.Unknown("Workout not found")) + + val updatedWorkout = workout.copy( + startTime = newStartTime, + needsSync = true + ) + + workoutDao.updateWorkout(updatedWorkout) + + // Queue for background sync immediately + syncManager.syncNow() + + return Result.success(Unit) + } + + private suspend fun fetchAndSaveWorkoutDetails( + apiService: FlexApiService, + workoutIds: List + ) { + if (workoutIds.isEmpty()) return + + coroutineScope { + val semaphore = Semaphore(SYNC_DETAIL_CONCURRENCY) + workoutIds.map { workoutId -> + async { + semaphore.withPermit { + try { + val detailResponse = apiService.getWorkoutById(workoutId) + if (detailResponse.isSuccessful) { + val fullWorkout = detailResponse.body() + if (fullWorkout != null) { + saveWorkoutWithExercisesAndSets(fullWorkout) + cacheExerciseTemplatesFromWorkout(fullWorkout) + } + } else { + com.jdluu.flexinsight.core.logger.AppLogger.e( + "Failed to fetch details for workout $workoutId" + ) + } + } catch (e: Exception) { + com.jdluu.flexinsight.core.logger.AppLogger.e( + "Exception fetching details for workout $workoutId: ${e.message}" + ) + } + } + } + }.awaitAll() + } + } + + /** + * Remote workout count used by the sync decision logic. + * Mirrors [WorkoutQueryRepositoryImpl.getRemoteWorkoutCount] without introducing a + * mutation-to-query dependency. + */ + private suspend fun fetchRemoteWorkoutCount(): Result { + val apiServiceResult = getApiService() + if (apiServiceResult is Result.Error) { + return Result.error(apiServiceResult.error) + } + + val apiService = (apiServiceResult as Result.Success).data + + return try { + if (!networkMonitor.hasNetworkConnection()) { + return Result.error(ApiError.NetworkError.NoConnection) + } + + val response = apiService.getWorkoutCount() + if (response.isSuccessful) { + val countResponse = response.body() + if (countResponse != null) { + Result.success(countResponse.workoutCount) + } else { + Result.error(ApiError.Unknown("Empty response body")) + } + } else { + val error = ErrorHandler.handleHttpException(retrofit2.HttpException(response)) + Result.error(error) + } + } catch (e: Exception) { + val error = ErrorHandler.handleError(e) + Result.error(error) + } + } + + private fun cacheExerciseTemplatesFromWorkout(fullWorkout: WorkoutResponse) { + fullWorkout.exercises?.forEach { exercise -> + exercise.exerciseTemplateId?.let { templateId -> + val cacheKey = CacheKeys.EXERCISE_TEMPLATES_FROM_EVENTS + val currentCache = cacheManager.get>( + cacheKey, + CacheTTL.EXERCISE_TEMPLATES_FROM_EVENTS + ) ?: emptyMap() + if (!currentCache.containsKey(templateId)) { + cacheManager.put(cacheKey, currentCache + (templateId to exercise.title)) + } + } + } + } + + companion object { + /** Limit parallel Hevy detail requests to avoid rate limits. */ + private const val SYNC_DETAIL_CONCURRENCY = 3 + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutQueryRepository.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutQueryRepository.kt new file mode 100644 index 0000000..b8452d4 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutQueryRepository.kt @@ -0,0 +1,35 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.Workout +import kotlinx.coroutines.flow.Flow + +/** + * Read/query concerns for workouts: local Room reads plus remote single-workout reads. + * Mutations and sync writes live in [WorkoutMutationRepository]. + */ +interface WorkoutQueryRepository { + fun invalidateApiService() + + fun getWorkouts(): Flow> + + fun getRecentWorkouts(limit: Int = 10): Flow> + + suspend fun getWorkoutById(workoutId: String): Result + + fun getWorkoutByIdFlow(workoutId: String): Flow + + fun getWorkoutCount(): Flow + + suspend fun getExercisesByWorkoutId(workoutId: String): List + + suspend fun getSetsByExerciseId(exerciseId: String): List + + suspend fun getRemoteWorkoutCount(): Result + + fun getWorkoutsByDateRange(startTimestamp: Long, endTimestamp: Long): Flow> + + suspend fun getMostRecentSyncedTimestamp(): Long? +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutQueryRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutQueryRepositoryImpl.kt new file mode 100644 index 0000000..34346a5 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutQueryRepositoryImpl.kt @@ -0,0 +1,193 @@ +package com.jdluu.flexinsight.data.repository + +import com.jdluu.flexinsight.core.errors.ApiError +import com.jdluu.flexinsight.core.errors.ErrorHandler +import com.jdluu.flexinsight.core.errors.Result +import com.jdluu.flexinsight.core.network.NetworkMonitor +import com.jdluu.flexinsight.data.api.FlexApiClient +import com.jdluu.flexinsight.data.api.FlexApiService +import com.jdluu.flexinsight.data.local.dao.ExerciseDao +import com.jdluu.flexinsight.data.local.dao.SetDao +import com.jdluu.flexinsight.data.local.dao.WorkoutDao +import com.jdluu.flexinsight.data.mapper.WorkoutMapper +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.Workout +import com.jdluu.flexinsight.data.preferences.ApiKeyManager +import kotlinx.coroutines.flow.Flow + +/** + * Read/query implementation for workouts. + * Serves local Room data first; falls back to the API only for single-workout fetches + * that are not cached locally. + */ +class WorkoutQueryRepositoryImpl( + private val workoutDao: WorkoutDao, + private val exerciseDao: ExerciseDao, + private val setDao: SetDao, + private val apiKeyManager: ApiKeyManager, + private val networkMonitor: NetworkMonitor, + private val apiClient: FlexApiClient, + private val mutationRepository: WorkoutMutationRepository +) : WorkoutQueryRepository { + private var apiService: FlexApiService? = null + private var currentApiKey: String? = null + + /** + * Gets API service, creating it if needed + */ + private suspend fun getApiService(): Result { + val apiKey = apiKeyManager.getApiKey() ?: return Result.error( + ApiError.AuthError.InvalidApiKey + ) + + // Recreate service if API key has changed + if (apiService == null || currentApiKey != apiKey) { + apiService = apiClient.createApiService(apiKey) + currentApiKey = apiKey + } + + val service = apiService ?: return Result.error(ApiError.Unknown("API service not initialized")) + return Result.success(service) + } + + /** + * Invalidates the API service (useful when API key is updated) + */ + override fun invalidateApiService() { + apiService = null + currentApiKey = null + } + + /** + * Get all workouts - returns Flow from Room immediately + */ + override fun getWorkouts(): Flow> { + return workoutDao.getAllWorkoutsFlow() + } + + /** + * Get recent workouts + */ + override fun getRecentWorkouts(limit: Int): Flow> { + return workoutDao.getRecentWorkoutsFlow(limit) + } + + /** + * Get workout by ID - checks Room first, then API if not found + */ + override suspend fun getWorkoutById(workoutId: String): Result { + // Check local database first + val cached = workoutDao.getWorkoutById(workoutId) + if (cached != null) { + return Result.success(cached) + } + + // Try to fetch from API + val apiServiceResult = getApiService() + if (apiServiceResult is Result.Error) { + return apiServiceResult + } + + val apiService = (apiServiceResult as Result.Success).data + + return try { + // Check network before API call + if (!networkMonitor.hasNetworkConnection()) { + return Result.error(ApiError.NetworkError.NoConnection) + } + + val response = apiService.getWorkoutById(workoutId) + + if (response.isSuccessful) { + val workoutResponse = response.body() ?: return Result.error( + ApiError.Unknown("Empty response body") + ) + val workout = WorkoutMapper.toWorkout(workoutResponse) + + // Save to database + mutationRepository.saveWorkoutWithExercisesAndSets(workoutResponse) + + Result.success(workout) + } else { + val error = ErrorHandler.handleHttpException( + retrofit2.HttpException(response) + ) + Result.error(error) + } + } catch (e: Exception) { + val error = ErrorHandler.handleError(e) + Result.error(error) + } + } + + /** + * Get workout by ID as Flow - returns database data immediately + */ + override fun getWorkoutByIdFlow(workoutId: String): Flow { + return workoutDao.getWorkoutByIdFlow(workoutId) + } + + /** + * Get workout count + */ + override fun getWorkoutCount(): Flow { + return workoutDao.getWorkoutCountFlow() + } + + /** + * Get remote workout count from API + */ + override suspend fun getRemoteWorkoutCount(): Result { + val apiServiceResult = getApiService() + if (apiServiceResult is Result.Error) { + return Result.error(apiServiceResult.error) + } + + val apiService = (apiServiceResult as Result.Success).data + + return try { + if (!networkMonitor.hasNetworkConnection()) { + return Result.error(ApiError.NetworkError.NoConnection) + } + + val response = apiService.getWorkoutCount() + if (response.isSuccessful) { + val countResponse = response.body() + if (countResponse != null) { + Result.success(countResponse.workoutCount) + } else { + Result.error(ApiError.Unknown("Empty response body")) + } + } else { + val error = ErrorHandler.handleHttpException(retrofit2.HttpException(response)) + Result.error(error) + } + } catch (e: Exception) { + val error = ErrorHandler.handleError(e) + Result.error(error) + } + } + + override suspend fun getExercisesByWorkoutId(workoutId: String): List { + return exerciseDao.getExercisesByWorkoutId(workoutId) + } + + override suspend fun getSetsByExerciseId(exerciseId: String): List { + return setDao.getSetsByExerciseId(exerciseId) + } + + /** + * Get workouts by date range + */ + override fun getWorkoutsByDateRange(startTimestamp: Long, endTimestamp: Long): Flow> { + return workoutDao.getWorkoutsByDateRangeFlow(startTimestamp, endTimestamp) + } + + /** + * Get most recent synced timestamp + */ + override suspend fun getMostRecentSyncedTimestamp(): Long? { + return workoutDao.getMostRecentSyncedTimestamp() + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutRepositoryImpl.kt b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutRepositoryImpl.kt index 9a03b6a..a1d66dd 100644 --- a/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutRepositoryImpl.kt +++ b/app/src/main/java/com/jdluu/flexinsight/data/repository/WorkoutRepositoryImpl.kt @@ -1,525 +1,80 @@ package com.jdluu.flexinsight.data.repository -import com.jdluu.flexinsight.core.errors.ApiError -import com.jdluu.flexinsight.core.errors.ErrorHandler import com.jdluu.flexinsight.core.errors.Result -import com.jdluu.flexinsight.core.network.NetworkMonitor -import com.jdluu.flexinsight.data.api.FlexApiClient -import com.jdluu.flexinsight.data.api.FlexApiService -import com.jdluu.flexinsight.data.local.dao.ExerciseDao -import com.jdluu.flexinsight.data.local.dao.SetDao -import com.jdluu.flexinsight.data.local.dao.WorkoutDao -import com.jdluu.flexinsight.data.cache.CacheKeys -import com.jdluu.flexinsight.data.cache.CacheManager -import com.jdluu.flexinsight.data.cache.CacheTTL import com.jdluu.flexinsight.data.model.Exercise import com.jdluu.flexinsight.data.model.Set import com.jdluu.flexinsight.data.model.Workout -import com.jdluu.flexinsight.data.model.WorkoutEvent import com.jdluu.flexinsight.data.model.WorkoutResponse -import com.jdluu.flexinsight.data.preferences.ApiKeyManager -import com.jdluu.flexinsight.data.preferences.SyncPreferencesManager -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit /** * Repository for workout-related operations. - * Handles CRUD operations and sync with API. + * Facade over [WorkoutQueryRepository] (reads/queries) and [WorkoutMutationRepository] + * (mutations and sync writes), kept for backward compatibility with existing + * ViewModels and use cases. */ class WorkoutRepositoryImpl( - private val workoutDao: com.jdluu.flexinsight.data.local.dao.WorkoutDao, - private val exerciseDao: com.jdluu.flexinsight.data.local.dao.ExerciseDao, - private val setDao: com.jdluu.flexinsight.data.local.dao.SetDao, - private val apiKeyManager: ApiKeyManager, - private val networkMonitor: com.jdluu.flexinsight.core.network.NetworkMonitor, - private val apiClient: com.jdluu.flexinsight.data.api.FlexApiClient, - private val cacheManager: com.jdluu.flexinsight.data.cache.CacheManager, - private val syncManager: com.jdluu.flexinsight.data.sync.SyncManager, - private val syncPreferencesManager: SyncPreferencesManager + private val queryRepository: WorkoutQueryRepository, + private val mutationRepository: WorkoutMutationRepository ) : WorkoutRepository { - private var apiService: FlexApiService? = null - private var currentApiKey: String? = null - - /** - * Gets API service, creating it if needed - */ - private suspend fun getApiService(): Result { - val apiKey = apiKeyManager.getApiKey() ?: return Result.error( - ApiError.AuthError.InvalidApiKey - ) - - // Recreate service if API key has changed - if (apiService == null || currentApiKey != apiKey) { - apiService = apiClient.createApiService(apiKey) - currentApiKey = apiKey - } - - val service = apiService ?: return Result.error(ApiError.Unknown("API service not initialized")) - return Result.success(service) - } - - /** - * Invalidates the API service (useful when API key is updated) - */ override fun invalidateApiService() { - apiService = null - currentApiKey = null + queryRepository.invalidateApiService() + mutationRepository.invalidateApiService() } - /** - * Get all workouts - returns Flow from Room immediately - */ override fun getWorkouts(): Flow> { - return workoutDao.getAllWorkoutsFlow() + return queryRepository.getWorkouts() } - /** - * Get recent workouts - */ override fun getRecentWorkouts(limit: Int): Flow> { - return workoutDao.getRecentWorkoutsFlow(limit) + return queryRepository.getRecentWorkouts(limit) } - /** - * Get workout by ID - checks Room first, then API if not found - */ override suspend fun getWorkoutById(workoutId: String): Result { - // Check local database first - val cached = workoutDao.getWorkoutById(workoutId) - if (cached != null) { - return Result.success(cached) - } - - // Try to fetch from API - val apiServiceResult = getApiService() - if (apiServiceResult is Result.Error) { - return apiServiceResult - } - - val apiService = (apiServiceResult as Result.Success).data - - return try { - // Check network before API call - if (!networkMonitor.hasNetworkConnection()) { - return Result.error(ApiError.NetworkError.NoConnection) - } - - val response = apiService.getWorkoutById(workoutId) - - if (response.isSuccessful) { - val workoutResponse = response.body() ?: return Result.error( - ApiError.Unknown("Empty response body") - ) - val workout = workoutResponse.toWorkout() - - // Save to database - saveWorkoutWithExercisesAndSets(workoutResponse) - - Result.success(workout) - } else { - val error = ErrorHandler.handleHttpException( - retrofit2.HttpException(response) - ) - Result.error(error) - } - } catch (e: Exception) { - val error = ErrorHandler.handleError(e) - Result.error(error) - } + return queryRepository.getWorkoutById(workoutId) } - /** - * Get workout by ID as Flow - returns database data immediately - */ override fun getWorkoutByIdFlow(workoutId: String): Flow { - return workoutDao.getWorkoutByIdFlow(workoutId) + return queryRepository.getWorkoutByIdFlow(workoutId) } - /** - * Get workout count - */ override fun getWorkoutCount(): Flow { - return workoutDao.getWorkoutCountFlow() - } - - /** - * Get remote workout count from API - */ - override suspend fun getRemoteWorkoutCount(): Result { - val apiServiceResult = getApiService() - if (apiServiceResult is Result.Error) { - return Result.error(apiServiceResult.error) - } - - val apiService = (apiServiceResult as Result.Success).data - - return try { - if (!networkMonitor.hasNetworkConnection()) { - return Result.error(ApiError.NetworkError.NoConnection) - } - - val response = apiService.getWorkoutCount() - if (response.isSuccessful) { - val countResponse = response.body() - if (countResponse != null) { - Result.success(countResponse.workoutCount) - } else { - Result.error(ApiError.Unknown("Empty response body")) - } - } else { - val error = ErrorHandler.handleHttpException(retrofit2.HttpException(response)) - Result.error(error) - } - } catch (e: Exception) { - val error = ErrorHandler.handleError(e) - Result.error(error) - } + return queryRepository.getWorkoutCount() } override suspend fun getExercisesByWorkoutId(workoutId: String): List { - return exerciseDao.getExercisesByWorkoutId(workoutId) + return queryRepository.getExercisesByWorkoutId(workoutId) } - override suspend fun getSetsByExerciseId(exerciseId: String): List { - return setDao.getSetsByExerciseId(exerciseId) + override suspend fun getSetsByExerciseId(exerciseId: String): List { + return queryRepository.getSetsByExerciseId(exerciseId) } - /** - * Get workouts by date range - */ - override fun getWorkoutsByDateRange(startTimestamp: Long, endTimestamp: Long): Flow> { - return workoutDao.getWorkoutsByDateRangeFlow(startTimestamp, endTimestamp) - } - - /** - * Sync workouts from API - * Tries events endpoint first for incremental sync, falls back to regular workouts endpoint - */ - override suspend fun syncWorkouts(): Result { - val apiServiceResult = getApiService() - if (apiServiceResult is Result.Error) { - return Result.error(apiServiceResult.error) - } - - // Check network before syncing - if (!networkMonitor.hasNetworkConnection()) { - return Result.error(ApiError.NetworkError.NoConnection) - } - - // Check if we have any workouts in the database (for incremental sync) - val mostRecentSynced = workoutDao.getMostRecentSyncedTimestamp() - val localCount = workoutDao.getWorkoutCount() - - // Check remote count to see if we are missing data (backfill needed) - // This prevents the issue where we have a recent timestamp but are missing older workouts - var shouldForceFullSync = false - try { - val remoteCountResult = getRemoteWorkoutCount() - if (remoteCountResult is Result.Success) { - val remoteCount = remoteCountResult.data - // If we have significantly fewer workouts than remote, force full sync - if (localCount < remoteCount) { - shouldForceFullSync = true - com.jdluu.flexinsight.core.logger.AppLogger.d("Force full sync: Local $localCount < Remote $remoteCount") - } - } - } catch (e: Exception) { - // Ignore error, proceed with standard logic - com.jdluu.flexinsight.core.logger.AppLogger.e("Failed to check remote count during sync: ${e.message}") - } - - val isIncrementalSync = mostRecentSynced != null && !shouldForceFullSync - - // Try events endpoint first for incremental sync - if (isIncrementalSync) { - val eventsResult = syncWorkoutsFromEvents() - if (eventsResult is Result.Success) { - return eventsResult - } - // If events endpoint fails (404, etc.), fall through to regular sync - } - - // Fallback to regular workouts endpoint - val apiService = (apiServiceResult as Result.Success).data - - return try { - - var page = 1 - var hasMore = true - var allWorkoutsExist = false - - while (hasMore && !allWorkoutsExist) { - val response = apiService.getWorkouts(page, 10) - - if (response.isSuccessful) { - val paginatedResponse = response.body() ?: return Result.error( - ApiError.Unknown("Empty response body") - ) - val workoutsList = paginatedResponse.workouts - - if (workoutsList == null || workoutsList.isEmpty()) { - hasMore = false - continue - } - - val workoutIds = workoutsList.map { it.id } - val existingIds = if (workoutIds.isEmpty()) { - emptySet() - } else { - workoutDao.getExistingWorkoutIds(workoutIds).toSet() - } - - if (isIncrementalSync && workoutIds.all { it in existingIds }) { - allWorkoutsExist = true - break - } - - val idsToFetch = workoutIds.filter { it !in existingIds } - fetchAndSaveWorkoutDetails(apiService, idsToFetch) - - // Check if there are more pages - hasMore = page < paginatedResponse.pageCount - page++ - } else { - val error = if (response.code() == 401 || response.code() == 403) { - ApiError.AuthError.InvalidApiKey - } else { - ErrorHandler.handleHttpException(retrofit2.HttpException(response)) - } - - if (error is ApiError.AuthError) { - invalidateApiService() - } - - return Result.error(error) - } - } - - Result.success(Unit) - } catch (e: Exception) { - val error = ErrorHandler.handleError(e) - Result.error(error) - } + override suspend fun getRemoteWorkoutCount(): Result { + return queryRepository.getRemoteWorkoutCount() } - /** - * Saves a workout with its exercises and sets to the database atomically - */ - override suspend fun saveWorkoutWithExercisesAndSets(workoutResponse: com.jdluu.flexinsight.data.model.WorkoutResponse) { - val workout = workoutResponse.toWorkout() - val exercises = mutableListOf() - val sets = mutableListOf() - - workoutResponse.exercises?.forEach { exerciseResponse -> - val exercise = exerciseResponse.toExercise(workoutResponse.id) - exercises.add(exercise) - - exerciseResponse.sets?.forEach { setResponse -> - sets.add(setResponse.toSet(exercise.id)) - } - } - - workoutDao.insertWorkoutWithDetails( - workout = workout, - exercises = exercises, - sets = sets, - exerciseDao = exerciseDao, - setDao = setDao - ) + override fun getWorkoutsByDateRange(startTimestamp: Long, endTimestamp: Long): Flow> { + return queryRepository.getWorkoutsByDateRange(startTimestamp, endTimestamp) } - /** - * Get most recent synced timestamp - */ override suspend fun getMostRecentSyncedTimestamp(): Long? { - return workoutDao.getMostRecentSyncedTimestamp() + return queryRepository.getMostRecentSyncedTimestamp() } - /** - * Converts timestamp (milliseconds) to ISO 8601 format for API - */ - private fun timestampToIso8601(timestampMillis: Long): String { - val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) - dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC") - return dateFormat.format(java.util.Date(timestampMillis)) + override suspend fun syncWorkouts(): Result { + return mutationRepository.syncWorkouts() } - /** - * Sync workouts from events endpoint - * Uses incremental sync with 'since' parameter based on lastSynced timestamp - */ - suspend fun syncWorkoutsFromEvents(): Result { - val apiServiceResult = getApiService() - if (apiServiceResult is Result.Error) { - return Result.error(apiServiceResult.error) - } - - // Check network before syncing - if (!networkMonitor.hasNetworkConnection()) { - return Result.error(ApiError.NetworkError.NoConnection) - } - - val apiService = (apiServiceResult as Result.Success).data - - return try { - // Get most recent synced timestamp - val mostRecentSynced = workoutDao.getMostRecentSyncedTimestamp() - val sinceParam = mostRecentSynced?.let { timestampToIso8601(it) } - - // If no previous sync, return error to fallback to regular sync - if (sinceParam == null) { - return Result.error(ApiError.Unknown("No previous sync timestamp, use regular sync")) - } - - var page = 1 - var hasMore = true - - while (hasMore) { - val response = apiService.getWorkoutEvents(page = page, pageSize = 10, since = sinceParam) - - if (response.isSuccessful) { - val eventsResponse = response.body() ?: return Result.error( - ApiError.Unknown("Empty response body") - ) - val events = eventsResponse.events - - if (events == null || events.isEmpty()) { - hasMore = false - continue - } - - val idsToRefresh = mutableListOf() - events.forEach { event -> - val workoutId = event.workoutId ?: return@forEach - when (event.type) { - "created", "updated" -> idsToRefresh.add(workoutId) - "deleted" -> { - workoutDao.softDeleteWorkoutById(workoutId) - syncPreferencesManager.recordDeletedWorkout() - } - } - } - fetchAndSaveWorkoutDetails(apiService, idsToRefresh.distinct()) - - // Check if there are more pages - hasMore = page < eventsResponse.pageCount - page++ - } else { - val error = if (response.code() == 401 || response.code() == 403) { - ApiError.AuthError.InvalidApiKey - } else { - ErrorHandler.handleHttpException(retrofit2.HttpException(response)) - } - - if (error is ApiError.AuthError) { - invalidateApiService() - } - - return Result.error(error) - } - } - - Result.success(Unit) - } catch (e: Exception) { - val error = ErrorHandler.handleError(e) - Result.error(error) - } + override suspend fun saveWorkoutWithExercisesAndSets(workoutResponse: WorkoutResponse) { + mutationRepository.saveWorkoutWithExercisesAndSets(workoutResponse) } - /** - * Update workout status (completed/incomplete) - */ override suspend fun updateWorkoutStatus(workoutId: String, isCompleted: Boolean, endTime: Long?): Result { - val workout = workoutDao.getWorkoutById(workoutId) ?: return Result.error(ApiError.Unknown("Workout not found")) - - val updatedWorkout = workout.copy( - endTime = if (isCompleted) (endTime ?: System.currentTimeMillis()) else null, - needsSync = true - ) - - workoutDao.updateWorkout(updatedWorkout) - - // Queue for background sync immediately - syncManager.syncNow() - - return Result.success(Unit) + return mutationRepository.updateWorkoutStatus(workoutId, isCompleted, endTime) } - /** - * Reschedule a workout - */ override suspend fun rescheduleWorkout(workoutId: String, newStartTime: Long): Result { - val workout = workoutDao.getWorkoutById(workoutId) ?: return Result.error(ApiError.Unknown("Workout not found")) - - val updatedWorkout = workout.copy( - startTime = newStartTime, - needsSync = true - ) - - workoutDao.updateWorkout(updatedWorkout) - - // Queue for background sync immediately - syncManager.syncNow() - - return Result.success(Unit) - } - - private suspend fun fetchAndSaveWorkoutDetails( - apiService: FlexApiService, - workoutIds: List - ) { - if (workoutIds.isEmpty()) return - - coroutineScope { - val semaphore = Semaphore(SYNC_DETAIL_CONCURRENCY) - workoutIds.map { workoutId -> - async { - semaphore.withPermit { - try { - val detailResponse = apiService.getWorkoutById(workoutId) - if (detailResponse.isSuccessful) { - val fullWorkout = detailResponse.body() - if (fullWorkout != null) { - saveWorkoutWithExercisesAndSets(fullWorkout) - cacheExerciseTemplatesFromWorkout(fullWorkout) - } - } else { - com.jdluu.flexinsight.core.logger.AppLogger.e( - "Failed to fetch details for workout $workoutId" - ) - } - } catch (e: Exception) { - com.jdluu.flexinsight.core.logger.AppLogger.e( - "Exception fetching details for workout $workoutId: ${e.message}" - ) - } - } - } - }.awaitAll() - } - } - - private fun cacheExerciseTemplatesFromWorkout(fullWorkout: WorkoutResponse) { - fullWorkout.exercises?.forEach { exercise -> - exercise.exerciseTemplateId?.let { templateId -> - val cacheKey = CacheKeys.EXERCISE_TEMPLATES_FROM_EVENTS - val currentCache = cacheManager.get>( - cacheKey, - CacheTTL.EXERCISE_TEMPLATES_FROM_EVENTS - ) ?: emptyMap() - if (!currentCache.containsKey(templateId)) { - cacheManager.put(cacheKey, currentCache + (templateId to exercise.title)) - } - } - } - } - - companion object { - /** Limit parallel Hevy detail requests to avoid rate limits. */ - private const val SYNC_DETAIL_CONCURRENCY = 3 + return mutationRepository.rescheduleWorkout(workoutId, newStartTime) } } diff --git a/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt b/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt index 0b2b549..ca2bc68 100644 --- a/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt +++ b/app/src/main/java/com/jdluu/flexinsight/di/RepositoryModule.kt @@ -57,7 +57,7 @@ object RepositoryModule { @Provides @Singleton - fun provideWorkoutRepository( + fun provideWorkoutMutationRepository( workoutDao: WorkoutDao, exerciseDao: ExerciseDao, setDao: SetDao, @@ -67,8 +67,8 @@ object RepositoryModule { cacheManager: CacheManager, syncManager: SyncManager, syncPreferencesManager: com.jdluu.flexinsight.data.preferences.SyncPreferencesManager - ): WorkoutRepository { - return WorkoutRepositoryImpl( + ): WorkoutMutationRepository { + return WorkoutMutationRepositoryImpl( workoutDao = workoutDao, exerciseDao = exerciseDao, setDao = setDao, @@ -81,6 +81,40 @@ object RepositoryModule { ) } + @Provides + @Singleton + fun provideWorkoutQueryRepository( + workoutDao: WorkoutDao, + exerciseDao: ExerciseDao, + setDao: SetDao, + apiKeyManager: ApiKeyManager, + networkMonitor: NetworkMonitor, + apiClient: FlexApiClient, + mutationRepository: WorkoutMutationRepository + ): WorkoutQueryRepository { + return WorkoutQueryRepositoryImpl( + workoutDao = workoutDao, + exerciseDao = exerciseDao, + setDao = setDao, + apiKeyManager = apiKeyManager, + networkMonitor = networkMonitor, + apiClient = apiClient, + mutationRepository = mutationRepository + ) + } + + @Provides + @Singleton + fun provideWorkoutRepository( + queryRepository: WorkoutQueryRepository, + mutationRepository: WorkoutMutationRepository + ): WorkoutRepository { + return WorkoutRepositoryImpl( + queryRepository = queryRepository, + mutationRepository = mutationRepository + ) + } + @Provides @Singleton fun provideRoutineRepository( @@ -101,26 +135,42 @@ object RepositoryModule { @Provides @Singleton - fun provideStatsRepository( + fun provideStatsMutationRepository(cacheManager: CacheManager): StatsMutationRepository { + return StatsMutationRepositoryImpl(cacheManager = cacheManager) + } + + @Provides + @Singleton + fun provideStatsQueryRepository( workoutDao: WorkoutDao, exerciseDao: ExerciseDao, setDao: SetDao, exerciseRepository: ExerciseRepository, - cacheManager: CacheManager, dispatcherProvider: DispatcherProvider, cacheStrategy: CacheStrategy - ): StatsRepository { - return StatsRepositoryImpl( + ): StatsQueryRepository { + return StatsQueryRepositoryImpl( workoutDao = workoutDao, exerciseDao = exerciseDao, setDao = setDao, exerciseRepository = exerciseRepository, - cacheManager = cacheManager, dispatcherProvider = dispatcherProvider, cacheStrategy = cacheStrategy ) } + @Provides + @Singleton + fun provideStatsRepository( + queryRepository: StatsQueryRepository, + mutationRepository: StatsMutationRepository + ): StatsRepository { + return StatsRepositoryImpl( + queryRepository = queryRepository, + mutationRepository = mutationRepository + ) + } + @Provides @Singleton fun provideFlexRepository( diff --git a/app/src/test/java/com/jdluu/flexinsight/data/mapper/ExerciseMapperTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/mapper/ExerciseMapperTest.kt new file mode 100644 index 0000000..57153ec --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/mapper/ExerciseMapperTest.kt @@ -0,0 +1,30 @@ +package com.jdluu.flexinsight.data.mapper + +import com.jdluu.flexinsight.data.model.ExerciseTemplateResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ExerciseMapperTest { + + @Test + fun toExerciseTemplate_mapsTitleToName() { + val template = ExerciseMapper.toExerciseTemplate( + ExerciseTemplateResponse(id = "tpl-1", title = "Barbell Bench Press", muscleGroup = "Chest") + ) + + assertEquals("tpl-1", template.id) + assertEquals("Barbell Bench Press", template.name) + assertEquals("Chest", template.muscleGroup) + } + + @Test + fun toExerciseTemplate_nullMuscleGroupStaysNull() { + val template = ExerciseMapper.toExerciseTemplate( + ExerciseTemplateResponse(id = "tpl-2", title = "Unknown Move", muscleGroup = null) + ) + + assertEquals("Unknown Move", template.name) + assertNull(template.muscleGroup) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/data/mapper/RoutineMapperTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/mapper/RoutineMapperTest.kt new file mode 100644 index 0000000..60bcb19 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/mapper/RoutineMapperTest.kt @@ -0,0 +1,128 @@ +package com.jdluu.flexinsight.data.mapper + +import com.jdluu.flexinsight.data.model.RoutineExerciseResponse +import com.jdluu.flexinsight.data.model.RoutineFolderResponse +import com.jdluu.flexinsight.data.model.RoutineResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RoutineMapperTest { + + // region toRoutine + + @Test + fun toRoutine_mapsBasicFields() { + val response = RoutineResponse( + id = "r1", + name = "Push Day", + exerciseCount = 2, + exercises = null + ) + + val routine = RoutineMapper.toRoutine(response) + + assertEquals("r1", routine.id) + assertEquals("Push Day", routine.name) + assertEquals(2, routine.exerciseCount) + assertNull(routine.exercises) + } + + @Test + fun toRoutine_prefersApiTitleOverTemplateMapping() { + val response = RoutineResponse( + id = "r1", + name = "Legs", + exerciseCount = 1, + exercises = listOf( + RoutineExerciseResponse(templateId = "t1", title = "API Title") + ) + ) + + val routine = RoutineMapper.toRoutine(response, mapOf("t1" to "Mapped Title")) + + assertEquals("API Title", routine.exercises?.single()?.name) + assertEquals("t1", routine.exercises?.single()?.templateId) + } + + @Test + fun toRoutine_fallsBackToTemplateMappingWhenTitleNull() { + val response = RoutineResponse( + id = "r2", + name = "Pull Day", + exerciseCount = 1, + exercises = listOf( + RoutineExerciseResponse(templateId = "t2", title = null) + ) + ) + + val routine = RoutineMapper.toRoutine(response, mapOf("t2" to "Mapped Name")) + + assertEquals("Mapped Name", routine.exercises?.single()?.name) + } + + @Test + fun toRoutine_nullExercisesStaysNull() { + val response = RoutineResponse(id = "r3", name = "Rest", exerciseCount = 0, exercises = null) + + val routine = RoutineMapper.toRoutine(response, mapOf("t1" to "Unused")) + + assertNull(routine.exercises) + } + + @Test + fun toRoutine_defaultMappingYieldsNullExerciseNames() { + val response = RoutineResponse( + id = "r4", + name = "Arms", + exerciseCount = 1, + exercises = listOf(RoutineExerciseResponse(templateId = "t9", title = null)) + ) + + val routine = RoutineMapper.toRoutine(response) + + assertNull(routine.exercises?.single()?.name) + } + + // endregion + + // region toRoutineExercise + + @Test + fun toRoutineExercise_prefersApiTitle() { + val exercise = RoutineMapper.toRoutineExercise( + RoutineExerciseResponse(templateId = "t1", title = "Bench Press"), + exerciseName = "Fallback" + ) + + assertEquals("t1", exercise.templateId) + assertEquals("Bench Press", exercise.name) + } + + @Test + fun toRoutineExercise_usesFallbackWhenTitleNull() { + val exercise = RoutineMapper.toRoutineExercise( + RoutineExerciseResponse(templateId = "t1", title = null), + exerciseName = "Fallback" + ) + + assertEquals("Fallback", exercise.name) + } + + // endregion + + // region toRoutineFolder + + @Test + fun toRoutineFolder_copiesFields() { + val folder = RoutineMapper.toRoutineFolder( + RoutineFolderResponse(id = 7, title = "Strength", index = 3, createdAt = "", updatedAt = "") + ) + + assertEquals(7, folder.id) + assertEquals("Strength", folder.title) + assertEquals(3, folder.index) + } + + // endregion +} diff --git a/app/src/test/java/com/jdluu/flexinsight/data/mapper/WorkoutMapperTest.kt b/app/src/test/java/com/jdluu/flexinsight/data/mapper/WorkoutMapperTest.kt new file mode 100644 index 0000000..eb08d43 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/data/mapper/WorkoutMapperTest.kt @@ -0,0 +1,222 @@ +package com.jdluu.flexinsight.data.mapper + +import com.jdluu.flexinsight.data.model.ExerciseResponse +import com.jdluu.flexinsight.data.model.SetResponse +import com.jdluu.flexinsight.data.model.WorkoutResponse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkoutMapperTest { + + // region toWorkout + + @Test + fun toWorkout_mapsBasicFields() { + val response = WorkoutResponse( + id = "w1", + title = "Push Day", + startTime = "2024-01-15T10:30:00Z", + endTime = "2024-01-15T11:30:00Z", + description = "Felt strong", + routineId = "r1", + exercises = null + ) + + val workout = WorkoutMapper.toWorkout(response) + + assertEquals("w1", workout.id) + assertEquals("Push Day", workout.name) + assertEquals(1705314600000L, workout.startTime) // 2024-01-15T10:30:00Z + assertEquals(1705318200000L, workout.endTime) // 2024-01-15T11:30:00Z + assertEquals("Felt strong", workout.notes) + assertEquals("r1", workout.routineId) + assertFalse(workout.needsSync) + assertFalse(workout.isDeleted) + assertTrue(workout.lastSynced > 0) + } + + @Test + fun toWorkout_nullEndTimeMapsToNull() { + val response = WorkoutResponse( + id = "w1", + title = null, + startTime = "2024-01-15T10:30:00Z", + endTime = null, + description = null, + routineId = null, + exercises = null + ) + + val workout = WorkoutMapper.toWorkout(response) + + assertNull(workout.endTime) + assertNull(workout.name) + assertNull(workout.notes) + assertNull(workout.routineId) + } + + @Test + fun toWorkout_parsesIsoWithZeroOffset() { + val response = WorkoutResponse( + id = "w2", + title = null, + startTime = "2025-12-12T18:27:13+00:00", + endTime = null, + description = null, + routineId = null, + exercises = null + ) + + val workout = WorkoutMapper.toWorkout(response) + + // Offset is normalized away; time is interpreted as UTC. + assertEquals(1765564033000L, workout.startTime) + } + + @Test + fun toWorkout_parsesIsoWithNonZeroOffsetAsUtcWallClock() { + val response = WorkoutResponse( + id = "w3", + title = null, + startTime = "2025-12-12T18:27:13+05:30", + endTime = null, + description = null, + routineId = null, + exercises = null + ) + + val workout = WorkoutMapper.toWorkout(response) + + // Existing behavior: the numeric offset is stripped, wall clock read as UTC. + assertEquals(1765564033000L, workout.startTime) + } + + @Test + fun toWorkout_unparseableTimestampFallsBackToCurrentTime() { + val before = System.currentTimeMillis() + val response = WorkoutResponse( + id = "w4", + title = null, + startTime = "not-a-timestamp", + endTime = null, + description = null, + routineId = null, + exercises = null + ) + + val workout = WorkoutMapper.toWorkout(response) + + val after = System.currentTimeMillis() + assertTrue(workout.startTime in before..after) + } + + // endregion + + // region toExercise + + @Test + fun toExercise_buildsIdFromIndexAndMapsFields() { + val response = ExerciseResponse( + index = 0, + title = "Bench Press", + exerciseTemplateId = "tpl-1", + notes = "pause at bottom", + restSeconds = 90, + sets = null + ) + + val exercise = WorkoutMapper.toExercise(response, workoutId = "w1") + + assertEquals("w1_exercise_0", exercise.id) + assertEquals("w1", exercise.workoutId) + assertEquals("tpl-1", exercise.exerciseTemplateId) + assertEquals("Bench Press", exercise.name) + assertEquals("pause at bottom", exercise.notes) + assertEquals(90, exercise.restDuration) + assertFalse(exercise.needsSync) + assertTrue(exercise.lastSynced > 0) + } + + @Test + fun toExercise_nullIndexUsesTitleHashInId() { + val response = ExerciseResponse( + index = null, + title = "Squat", + exerciseTemplateId = null, + notes = null, + restSeconds = null, + sets = null + ) + + val exercise = WorkoutMapper.toExercise(response, workoutId = "w9") + + assertEquals("w9_exercise_${"Squat".hashCode()}", exercise.id) + assertNull(exercise.exerciseTemplateId) + assertNull(exercise.restDuration) + } + + // endregion + + // region toSet + + @Test + fun toSet_mapsAllFieldsAndBuildsIdFromIndex() { + val response = SetResponse( + index = 2, + type = "warmup", + weightKg = 62.5, + reps = 8, + rpe = 7.5, + distanceMeters = 100.5, + durationSeconds = 60, + customMetric = null, + personalRecord = true + ) + + val set = WorkoutMapper.toSet(response, exerciseId = "w1_exercise_0") + + assertEquals("w1_exercise_0_set_2", set.id) + assertEquals("w1_exercise_0", set.exerciseId) + assertEquals(3, set.number) // 0-based API index becomes 1-based number + assertEquals(62.5, set.weight!!, 0.0) + assertEquals(8, set.reps) + assertEquals(7.5, set.rpe!!, 0.0) + assertEquals(100.5, set.distance!!, 0.0) + assertEquals(60, set.duration) + assertNull(set.restDuration) // Not provided by API + assertEquals("warmup", set.notes) // Set type stored as notes + assertTrue(set.isPersonalRecord) + assertFalse(set.needsSync) + assertTrue(set.lastSynced > 0) + } + + @Test + fun toSet_nullPersonalRecordDefaultsToFalse() { + val response = SetResponse( + index = 0, + type = null, + weightKg = null, + reps = null, + rpe = null, + distanceMeters = null, + durationSeconds = null, + customMetric = null, + personalRecord = null + ) + + val set = WorkoutMapper.toSet(response, exerciseId = "e1") + + assertFalse(set.isPersonalRecord) + assertNull(set.weight) + assertNull(set.reps) + assertNull(set.rpe) + assertNull(set.distance) + assertNull(set.duration) + assertNull(set.notes) + } + + // endregion +}