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 30e5883..62dd59d 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 @@ -15,7 +15,11 @@ import java.time.ZoneId import java.time.temporal.ChronoUnit import java.time.LocalDate import java.time.LocalTime -import com.jdluu.flexinsight.domain.util.StatsCalculator +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 @@ -54,13 +58,9 @@ class StatsRepositoryImpl @Inject constructor( } val totalSets = allSets.size - val totalVolume = allSets.sumOf { set -> - (set.weight ?: 0.0) * (set.reps ?: 0) - } + val totalVolume = VolumeCalculator.totalSetVolume(allSets) - val durationMinutes = workout.endTime?.let { endTime -> - (endTime - workout.startTime) / (1000 * 60) - } ?: 0L + val durationMinutes = DurationCalculator.workoutDurationMinutes(workout) SingleWorkoutStats( durationMinutes = durationMinutes, @@ -127,8 +127,8 @@ class StatsRepositoryImpl @Inject constructor( totalVolumePrevious = previousVolume, totalWorkoutsCurrent = currentWorkouts.size, totalWorkoutsPrevious = previousWorkouts.size, - avgDurationCurrent = averageDurationMinutes(currentWorkouts), - avgDurationPrevious = averageDurationMinutes(previousWorkouts) + avgDurationCurrent = DurationCalculator.averageDurationMinutes(currentWorkouts), + avgDurationPrevious = DurationCalculator.averageDurationMinutes(previousWorkouts) ) } @@ -148,7 +148,7 @@ class StatsRepositoryImpl @Inject constructor( val currentVolume = calculateTotalVolumeForWorkouts(currentWorkouts) val previousVolume = calculateTotalVolumeForWorkouts(previousWorkouts) - val percentageChange = StatsCalculator.calculateVolumeChange(currentVolume, previousVolume) + val percentageChange = VolumeCalculator.changePercent(currentVolume, previousVolume) VolumeTrend( currentVolume = currentVolume, @@ -182,7 +182,7 @@ class StatsRepositoryImpl @Inject constructor( val workouts = workoutDao.getWorkoutsByDateRangeFlow(startDate, endDate).first() - StatsCalculator.calculateDurationTrend(workouts, startDate, endDate) + DurationCalculator.durationTrend(workouts, startDate, endDate) } } @@ -197,7 +197,7 @@ class StatsRepositoryImpl @Inject constructor( val workouts = workoutDao.getWorkoutsByDateRangeFlow(weekStart, weekEnd).first() val completed = workouts.size - val status = StatsCalculator.calculateGoalStatus(completed, target) + val status = TrainingLoadCalculator.goalStatus(completed, target) WeeklyGoalProgress( completed = completed, @@ -243,8 +243,8 @@ class StatsRepositoryImpl @Inject constructor( * Get planned workouts for a specific day */ override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List = withContext(dispatcherProvider.default) { - val dayStart = StatsCalculator.getStartOfDay(timestamp) - val dayEnd = StatsCalculator.getEndOfDay(timestamp) + val dayStart = DurationCalculator.startOfDay(timestamp) + val dayEnd = DurationCalculator.endOfDay(timestamp) val workouts = workoutDao.getWorkoutsByDateRangeFlow(dayStart, dayEnd).first() val workoutIds = workouts.map { it.id } @@ -262,14 +262,13 @@ class StatsRepositoryImpl @Inject constructor( workouts.map { workout -> val exercises = exercisesByWorkout[workout.id] ?: emptyList() val totalVolume = exercises.sumOf { exercise -> - val sets = setsByExercise[exercise.id] ?: emptyList() - sets.sumOf { set -> (set.weight ?: 0.0) * (set.reps ?: 0) } + VolumeCalculator.totalSetVolume(setsByExercise[exercise.id] ?: emptyList()) } PlannedWorkout( id = workout.id, name = workout.name ?: "Workout", - duration = workout.endTime?.let { (it - workout.startTime) / (1000 * 60) }, - intensity = StatsCalculator.calculateAbsoluteIntensity(totalVolume), + duration = workout.endTime?.let { DurationCalculator.workoutDurationMinutes(workout) }, + intensity = VolumeCalculator.absoluteIntensity(totalVolume), isCompleted = workout.endTime != null, routineId = workout.routineId, exerciseCount = exercises.size @@ -282,7 +281,7 @@ class StatsRepositoryImpl @Inject constructor( */ override suspend fun getVolumeBalance(weeks: Int): VolumeBalance = withContext(dispatcherProvider.default) { val muscleGroupProgress = getMuscleGroupProgress(weeks) - StatsCalculator.calculateVolumeBalance(muscleGroupProgress) + VolumeCalculator.volumeBalance(muscleGroupProgress) } /** @@ -307,9 +306,7 @@ class StatsRepositoryImpl @Inject constructor( */ override suspend fun calculateAccountAgeDays(): Int { val memberSince = getMemberSinceDate() ?: return 0 - val now = System.currentTimeMillis() - val daysDiff = (now - memberSince) / (1000 * 60 * 60 * 24) - return daysDiff.toInt().coerceAtLeast(0) + return DurationCalculator.accountAgeDays(memberSince, System.currentTimeMillis()) } /** @@ -362,7 +359,7 @@ class StatsRepositoryImpl @Inject constructor( setDao.getSetsByExerciseId(exerciseId) } - return StatsCalculator.calculateTotalVolume(workouts, allExercises, allSets) + return VolumeCalculator.totalVolume(workouts, allExercises, allSets) } /** @@ -423,13 +420,13 @@ class StatsRepositoryImpl @Inject constructor( 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 = StatsCalculator.calculateTotalVolume(workouts, allExercises, allSets) + val totalVolume = VolumeCalculator.totalVolume(workouts, allExercises, allSets) val averageVolume = if (totalWorkouts > 0) totalVolume / totalWorkouts else 0.0 val totalSets = allSets.size - val totalDuration = StatsCalculator.calculateTotalDuration(workouts) + val totalDuration = DurationCalculator.totalDuration(workouts) val averageDuration = if (totalWorkouts > 0) totalDuration / totalWorkouts else 0L - val currentStreak = StatsCalculator.calculateStreak(workouts) - val longestStreak = StatsCalculator.calculateLongestStreak(workouts) + val currentStreak = StreakCalculator.currentStreak(workouts) + val longestStreak = StreakCalculator.longestStreak(workouts) val weeklyProgress = computeWeeklyProgress(4) val bestWeek = weeklyProgress.maxByOrNull { it.totalVolume } return WorkoutStats( @@ -461,7 +458,7 @@ class StatsRepositoryImpl @Inject constructor( .get(weekFields.weekOfWeekBasedYear()) }.map { (_, weekWorkouts) -> val weekStart = weekWorkouts.minOfOrNull { it.startTime } ?: 0L - val totalVolume = StatsCalculator.calculateTotalVolume(weekWorkouts, allExercises, allSets) + val totalVolume = VolumeCalculator.totalVolume(weekWorkouts, allExercises, allSets) WeeklyProgress( weekStartDate = weekStart, totalVolume = totalVolume, @@ -505,7 +502,7 @@ class StatsRepositoryImpl @Inject constructor( allExercises.forEach { exercise -> val muscleGroup = exerciseRepository.getMuscleGroupForExercise(exercise) ?: return@forEach val sets = allSets[exercise.id] ?: emptyList() - val exerciseVolume = sets.sumOf { (it.weight ?: 0.0) * (it.reps ?: 0) } + val exerciseVolume = VolumeCalculator.totalSetVolume(sets) val current = muscleGroupData[muscleGroup] ?: (0.0 to 0) muscleGroupData[muscleGroup] = (current.first + exerciseVolume) to (current.second + sets.size) } @@ -517,14 +514,13 @@ class StatsRepositoryImpl @Inject constructor( muscleGroup = muscleGroup, volume = volume, sets = sets, - intensity = StatsCalculator.calculateRelativeIntensity(volume, averageVolume) + intensity = VolumeCalculator.relativeIntensity(volume, averageVolume) ) }.sortedByDescending { it.volume } } private suspend fun computeMuscleRecovery(): Map = withContext(dispatcherProvider.io) { val now = System.currentTimeMillis() - val recoveryTimeMs = 72 * 60 * 60 * 1000L val sevenDaysAgo = now - (7 * 24 * 60 * 60 * 1000L) val workouts = workoutDao.getWorkoutsSinceFlow(sevenDaysAgo).first().filter { !it.isDeleted } val lastTrainedMap = mutableMapOf() @@ -538,18 +534,6 @@ class StatsRepositoryImpl @Inject constructor( } } } - MuscleGroup.values().associateWith { group -> - val lastTrained = lastTrainedMap[group] ?: 0L - if (lastTrained == 0L) 1.0f - else ((now - lastTrained).toFloat() / recoveryTimeMs).coerceIn(0f, 1f) - } - } - - private fun averageDurationMinutes(workouts: List): Long { - if (workouts.isEmpty()) return 0L - val durations = workouts.mapNotNull { w -> - w.endTime?.let { (it - w.startTime) / (1000 * 60) } - } - return if (durations.isEmpty()) 0L else durations.average().toLong() + RecoveryScoreCalculator.muscleRecoveryStatus(now, lastTrainedMap) } } diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/calc/DurationCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/calc/DurationCalculator.kt new file mode 100644 index 0000000..d7eac94 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/calc/DurationCalculator.kt @@ -0,0 +1,114 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.Workout +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** + * Pure workout-duration math plus day-window helpers. All minute values truncate + * partial minutes; day boundaries use the system time zone. + */ +object DurationCalculator { + + private const val MILLIS_PER_MINUTE = 1000L * 60 + private const val MILLIS_PER_DAY = MILLIS_PER_MINUTE * 60 * 24 + + /** Duration of a single workout in whole minutes; 0 while endTime is null. */ + fun workoutDurationMinutes(workout: Workout): Long = + workout.endTime?.let { (it - workout.startTime) / MILLIS_PER_MINUTE } ?: 0L + + /** Total duration in whole minutes across workouts, skipping open-ended ones. */ + fun totalDuration(workouts: List): Long = + workouts.filter { it.endTime != null }.sumOf { workoutDurationMinutes(it) } + + /** Mean duration in whole minutes; 0 for empty input or when nothing has an endTime. */ + fun averageDurationMinutes(workouts: List): Long { + if (workouts.isEmpty()) return 0L + val durations = workouts.mapNotNull { w -> + w.endTime?.let { (it - w.startTime) / MILLIS_PER_MINUTE } + } + return if (durations.isEmpty()) 0L else durations.average().toLong() + } + + /** Human label such as "45m" or "1h 30m"; non-positive input renders as "0m". */ + fun durationLabel(minutes: Long): String { + if (minutes <= 0) return "0m" + val hours = minutes / 60 + val minutesPart = minutes % 60 + return if (hours > 0) "${hours}h ${minutesPart}m" else "${minutesPart}m" + } + + /** Whole days elapsed since [memberSinceMs]; never negative. */ + fun accountAgeDays(memberSinceMs: Long, nowMs: Long): Int = + ((nowMs - memberSinceMs) / MILLIS_PER_DAY).toInt().coerceAtLeast(0) + + /** First millisecond of the local day containing [timestamp]. */ + fun startOfDay(timestamp: Long): Long { + return Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + + /** Last millisecond of the local day containing [timestamp]. */ + fun endOfDay(timestamp: Long): Long { + return Instant.ofEpochMilli(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .atTime(LocalTime.MAX) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + + /** + * Average duration per weekday for workouts within [startDate, endDate] inclusive, + * returned Monday through Saturday in display order. Workouts on Sunday are + * aggregated internally but never emitted, matching historical output. + */ + fun durationTrend( + workouts: List, + startDate: Long, + endDate: Long + ): List { + val dayGroups = mutableMapOf>() + + // Initialize map with empty lists + DayOfWeek.values().forEach { day -> + dayGroups[day] = mutableListOf() + } + + workouts.forEach { workout -> + if (workout.endTime != null && workout.startTime >= startDate && workout.startTime <= endDate) { + val workoutDate = Instant.ofEpochMilli(workout.startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val dayOfWeek = workoutDate.dayOfWeek + val duration = workoutDurationMinutes(workout) + + dayGroups.getOrPut(dayOfWeek) { mutableListOf() }.add(duration) + } + } + + // Map to display order M, T, W, T, F, S + val displayDays = listOf( + DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY + ) + + return displayDays.map { day -> + val durations = dayGroups[day] ?: emptyList() + val avg = if (durations.isNotEmpty()) durations.average().toLong() else 0L + DailyDurationData( + dayOfWeek = day.name.take(1), // M, T, W... + averageDuration = avg + ) + } + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/calc/PersonalRecordCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/calc/PersonalRecordCalculator.kt new file mode 100644 index 0000000..385e166 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/calc/PersonalRecordCalculator.kt @@ -0,0 +1,16 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.Set + +/** + * Pure personal-record math: best-set tonnage and PR-flag detection. + */ +object PersonalRecordCalculator { + + /** Heaviest single set by tonnage; empty input yields 0.0. */ + fun bestSetVolume(sets: List): Double = + sets.maxOfOrNull { VolumeCalculator.setVolume(it.weight, it.reps) } ?: 0.0 + + /** True when any set carries the personal-record flag. */ + fun hasPersonalRecord(sets: List): Boolean = sets.any { it.isPersonalRecord } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/calc/RecoveryScoreCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/calc/RecoveryScoreCalculator.kt new file mode 100644 index 0000000..41b2ee3 --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/calc/RecoveryScoreCalculator.kt @@ -0,0 +1,38 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.MuscleGroup + +/** + * Pure muscle-recovery math. Recovery for a group is the fraction of the recovery + * window elapsed since it was last trained, clamped to 0..1. + */ +object RecoveryScoreCalculator { + + /** Hours of rest before a trained muscle group is considered fully recovered. */ + const val RECOVERY_WINDOW_MS: Long = 72 * 60 * 60 * 1000L + + /** Elapsed fraction of the recovery window, clamped to 0..1. */ + fun recoveryFraction(nowMs: Long, lastTrainedMs: Long, recoveryWindowMs: Long = RECOVERY_WINDOW_MS): Float = + ((nowMs - lastTrainedMs).toFloat() / recoveryWindowMs).coerceIn(0f, 1f) + + /** + * Recovery status for every muscle group. Groups without a recorded session + * (missing or zero timestamp) are treated as fully recovered. + */ + fun muscleRecoveryStatus( + nowMs: Long, + lastTrainedByGroup: Map, + recoveryWindowMs: Long = RECOVERY_WINDOW_MS + ): Map = + MuscleGroup.values().associateWith { group -> + val lastTrained = lastTrainedByGroup[group] ?: 0L + if (lastTrained == 0L) 1.0f + else recoveryFraction(nowMs, lastTrained, recoveryWindowMs) + } + + /** + * Average recovery across groups scaled to 0-100. An empty map yields 0. + */ + fun overallRecoveryScore(recovery: Map): Int = + (recovery.values.average() * 100).toInt().coerceIn(0, 100) +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/calc/StreakCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/calc/StreakCalculator.kt new file mode 100644 index 0000000..86ef5cb --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/calc/StreakCalculator.kt @@ -0,0 +1,95 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.Workout +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +/** + * Pure streak math over workout history. Days are derived in the system time zone + * and multiple workouts on one day collapse to a single date. + */ +object StreakCalculator { + + private const val DEFAULT_MAX_DISPLAY_DAYS = 7 + + /** + * Consecutive-day run ending today or yesterday; 0 when the most recent workout + * is older than yesterday. + */ + fun currentStreak(workouts: List): Int { + if (workouts.isEmpty()) return 0 + + // Group by LocalDate to handle multiple workouts per day + val workoutDates = workouts + .map { Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() } + .distinct() + .sortedDescending() + + val today = LocalDate.now() + val yesterday = today.minusDays(1) + + if (workoutDates.isEmpty()) return 0 + + // Streak must start today or yesterday + val latestWorkout = workoutDates.first() + if (latestWorkout != today && latestWorkout != yesterday) { + return 0 + } + + var streak = 0 + var currentDate = latestWorkout + + // Check for consecutive days backwards + for (i in workoutDates.indices) { + if (workoutDates[i] == currentDate) { + streak++ + currentDate = currentDate.minusDays(1) + } else { + break + } + } + + return streak + } + + /** Longest consecutive-day run anywhere in the workout history. */ + fun longestStreak(workouts: List): Int { + if (workouts.isEmpty()) return 0 + + val workoutDates = workouts + .map { Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() } + .distinct() + .sorted() + + var longestStreak = 0 + var currentStreak = 0 + var lastDate: LocalDate? = null + + for (date in workoutDates) { + if (lastDate == null) { + currentStreak = 1 + lastDate = date + } else { + val daysDiff = ChronoUnit.DAYS.between(lastDate, date) + if (daysDiff == 1L) { + currentStreak++ + } else { + longestStreak = maxOf(longestStreak, currentStreak) + currentStreak = 1 + } + lastDate = date + } + } + + return maxOf(longestStreak, currentStreak) + } + + /** + * Fraction (0..1) of a display bar filled by [streak], capped at [maxDisplayDays]. + * Used for the dashboard streak indicator. + */ + fun barProgress(streak: Int, maxDisplayDays: Int = DEFAULT_MAX_DISPLAY_DAYS): Float = + (streak.coerceAtMost(maxDisplayDays) / maxDisplayDays.toFloat()).coerceIn(0f, 1f) +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/calc/TrainingLoadCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/calc/TrainingLoadCalculator.kt new file mode 100644 index 0000000..e7546ac --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/calc/TrainingLoadCalculator.kt @@ -0,0 +1,78 @@ +package com.jdluu.flexinsight.domain.calc + +/** + * Pure composite training-load scoring: Hevy volume attainment, cardio frequency, + * sleep quality bands, and the weighted blend shown as the daily load score. + */ +object TrainingLoadCalculator { + + private const val VOLUME_WEIGHT = 0.5f + private const val CARDIO_WEIGHT = 0.2f + private const val SLEEP_WEIGHT = 0.3f + + private const val FALLBACK_SCORE_WITH_WORKOUTS = 65 + private const val FALLBACK_SCORE_NO_DATA = 30 + + /** + * Hevy volume score. Prefers weekly goal completion (clamped to 0-100); falls back + * to a fixed score when any workouts exist, and to the floor score otherwise. + */ + fun hevyVolumeScore(goalCompleted: Int?, goalTarget: Int?, totalWorkouts: Int?): Int = when { + goalCompleted != null && goalTarget != null && goalTarget > 0 -> + ((goalCompleted.toFloat() / goalTarget) * 100).toInt().coerceIn(0, 100) + totalWorkouts != null && totalWorkouts > 0 -> FALLBACK_SCORE_WITH_WORKOUTS + else -> FALLBACK_SCORE_NO_DATA + } + + /** Cardio score: 15 points per session this week, capped at 100. */ + fun cardioScore(cardioSessionsThisWeek: Int): Int = + (cardioSessionsThisWeek * 15).coerceIn(0, 100) + + /** Sleep score banding; missing data scores a neutral 50. */ + fun sleepScore(sleepHoursLastNight: Double?): Int = when (sleepHoursLastNight) { + null -> 50 + in 7.0..Double.MAX_VALUE -> 90 + in 6.0..7.0 -> 70 + in 5.0..6.0 -> 50 + else -> 25 + } + + /** Weighted blend of volume (50%), cardio (20%), and sleep (30%), clamped to 0-100. */ + fun overall(volumeScore: Int, cardioScore: Int, sleepScore: Int): Int = + ((volumeScore * VOLUME_WEIGHT) + (cardioScore * CARDIO_WEIGHT) + (sleepScore * SLEEP_WEIGHT)) + .toInt() + .coerceIn(0, 100) + + /** Label bucket for an overall score. */ + fun label(overall: Int): String = when { + overall >= 80 -> "High load" + overall >= 55 -> "Moderate load" + else -> "Recovery focus" + } + + /** Human-readable breakdown appended to the load card. */ + fun detail( + volumeScore: Int, + hasHealthData: Boolean, + sleepHoursLastNight: Double?, + stepsToday: Long?, + cardioSessionsThisWeek: Int + ): String = buildString { + append("Hevy volume $volumeScore/100") + if (hasHealthData) { + sleepHoursLastNight?.let { append(", sleep ${"%.1f".format(it)}h") } + stepsToday?.let { append(", steps $it") } + if (cardioSessionsThisWeek > 0) { + append(", $cardioSessionsThisWeek cardio sessions") + } + } + } + + /** Weekly goal status label based on completion ratio. */ + fun goalStatus(completed: Int, target: Int): String = when { + completed >= target -> "On Track" + completed >= target * 0.7 -> "On Track" + completed >= target * 0.5 -> "Behind" + else -> "Behind" + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/calc/VolumeCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/calc/VolumeCalculator.kt new file mode 100644 index 0000000..2b3b36d --- /dev/null +++ b/app/src/main/java/com/jdluu/flexinsight/domain/calc/VolumeCalculator.kt @@ -0,0 +1,118 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.Workout + +/** + * Pure volume math: per-set tonnage, aggregate totals, period-over-period change, + * muscle-group share, push/pull/legs/cardio balance, and intensity classification. + */ +object VolumeCalculator { + + /** Tonnage of a single set; null weight or reps contributes zero. */ + fun setVolume(weight: Double?, reps: Int?): Double = (weight ?: 0.0) * (reps ?: 0) + + /** Summed tonnage across a list of sets. */ + fun totalSetVolume(sets: List): Double = sets.sumOf { setVolume(it.weight, it.reps) } + + /** + * Total volume across the given workouts. Only sets whose exercise belongs to one + * of the workouts are counted; orphan exercises and sets are ignored. + */ + fun totalVolume( + workouts: List, + allExercises: List, + allSets: List + ): Double { + val exercisesByWorkout = allExercises.groupBy { it.workoutId } + val setsByExercise = allSets.groupBy { it.exerciseId } + + return workouts.sumOf { workout -> + val exercises = exercisesByWorkout[workout.id] ?: emptyList() + exercises.sumOf { exercise -> + val sets = setsByExercise[exercise.id] ?: emptyList() + totalSetVolume(sets) + } + } + } + + /** Headline volume summed over weekly progress entries. */ + fun sumWeeklyVolume(progress: List): Double = progress.sumOf { it.totalVolume } + + /** Compact display label: whole thousands collapse to "12k", otherwise integer value. */ + fun compactThousandsLabel(value: Double): String = + if (value >= 1000) "${(value / 1000).toInt()}k" else value.toInt().toString() + + /** Percent share of [part] within [total], truncated toward zero; 0 when total is not positive. */ + fun sharePercent(part: Double, total: Double): Int = + if (total > 0) ((part / total) * 100).toInt() else 0 + + /** + * Percentage change between two periods. A zero previous period yields 100% when + * there is current volume and 0% otherwise. + */ + fun changePercent(current: Double, previous: Double): Double = + if (previous > 0) { + ((current - previous) / previous) * 100.0 + } else { + if (current > 0) 100.0 else 0.0 + } + + /** Relative intensity bucket ("HI", "MD", "LO") versus the average volume. */ + fun relativeIntensity(volume: Double, averageVolume: Double): String = when { + volume >= averageVolume * 1.5 -> "HI" + volume >= averageVolume * 0.7 -> "MD" + else -> "LO" + } + + /** Absolute intensity label based on raw volume thresholds. */ + fun absoluteIntensity(totalVolume: Double): String = when { + totalVolume > 5000 -> "High Intensity" + totalVolume > 2000 -> "Medium Intensity" + else -> "Aerobic" + } + + /** + * Volume split across push, pull, legs, and cardio categories. Matching is a + * case-insensitive substring check evaluated in that order; when no categorized + * volume exists the split defaults to quarters. + */ + fun volumeBalance(muscleGroupProgress: List): VolumeBalance { + val pushGroups = setOf("Chest", "Shoulders", "Triceps") + val pullGroups = setOf("Back", "Biceps") + val legsGroups = setOf("Legs", "Quads", "Hamstrings", "Glutes", "Calves") + val cardioGroups = setOf("Cardio") + + var pushVolume = 0.0 + var pullVolume = 0.0 + var legsVolume = 0.0 + var cardioVolume = 0.0 + + muscleGroupProgress.forEach { progress -> + val group = progress.muscleGroup + when { + pushGroups.any { group.contains(it, ignoreCase = true) } -> pushVolume += progress.volume + pullGroups.any { group.contains(it, ignoreCase = true) } -> pullVolume += progress.volume + legsGroups.any { group.contains(it, ignoreCase = true) } -> legsVolume += progress.volume + cardioGroups.any { group.contains(it, ignoreCase = true) } -> cardioVolume += progress.volume + } + } + + val totalVolume = pushVolume + pullVolume + legsVolume + cardioVolume + + return if (totalVolume > 0) { + VolumeBalance( + push = (pushVolume / totalVolume).toFloat(), + pull = (pullVolume / totalVolume).toFloat(), + legs = (legsVolume / totalVolume).toFloat(), + cardio = (cardioVolume / totalVolume).toFloat() + ) + } else { + VolumeBalance(0.25f, 0.25f, 0.25f, 0.25f) + } + } +} diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCase.kt b/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCase.kt index d543457..2ea9c2f 100644 --- a/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCase.kt +++ b/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CalculateTrainingLoadUseCase.kt @@ -3,6 +3,7 @@ package com.jdluu.flexinsight.domain.usecase import com.jdluu.flexinsight.data.health.HealthConnectRepository import com.jdluu.flexinsight.data.health.HealthConnectSnapshot import com.jdluu.flexinsight.data.repository.FlexRepository +import com.jdluu.flexinsight.domain.calc.TrainingLoadCalculator import com.jdluu.flexinsight.domain.model.TrainingLoadScore import javax.inject.Inject @@ -15,41 +16,25 @@ class CalculateTrainingLoadUseCase @Inject constructor( val goal = runCatching { flexRepository.getWeeklyGoalProgress() }.getOrNull() val health: HealthConnectSnapshot = healthConnectRepository.readSnapshot() - val volumeScore = when { - goal != null && goal.target > 0 -> - ((goal.completed.toFloat() / goal.target) * 100).toInt().coerceIn(0, 100) - stats != null && stats.totalWorkouts > 0 -> 65 - else -> 30 - } - - val cardioScore = (health.cardioSessionsThisWeek * 15).coerceIn(0, 100) - val sleepScore = when (val h = health.sleepHoursLastNight) { - null -> 50 - in 7.0..Double.MAX_VALUE -> 90 - in 6.0..7.0 -> 70 - in 5.0..6.0 -> 50 - else -> 25 - } + val volumeScore = TrainingLoadCalculator.hevyVolumeScore( + goalCompleted = goal?.completed, + goalTarget = goal?.target, + totalWorkouts = stats?.totalWorkouts + ) + val cardioScore = TrainingLoadCalculator.cardioScore(health.cardioSessionsThisWeek) + val sleepScore = TrainingLoadCalculator.sleepScore(health.sleepHoursLastNight) - val overall = ((volumeScore * 0.5f) + (cardioScore * 0.2f) + (sleepScore * 0.3f)).toInt() - .coerceIn(0, 100) + val overall = TrainingLoadCalculator.overall(volumeScore, cardioScore, sleepScore) - val label = when { - overall >= 80 -> "High load" - overall >= 55 -> "Moderate load" - else -> "Recovery focus" - } + val label = TrainingLoadCalculator.label(overall) - val detail = buildString { - append("Hevy volume $volumeScore/100") - if (health.hasData) { - health.sleepHoursLastNight?.let { append(", sleep ${"%.1f".format(it)}h") } - health.stepsToday?.let { append(", steps $it") } - if (health.cardioSessionsThisWeek > 0) { - append(", ${health.cardioSessionsThisWeek} cardio sessions") - } - } - } + val detail = TrainingLoadCalculator.detail( + volumeScore = volumeScore, + hasHealthData = health.hasData, + sleepHoursLastNight = health.sleepHoursLastNight, + stepsToday = health.stepsToday, + cardioSessionsThisWeek = health.cardioSessionsThisWeek + ) return TrainingLoadScore( overall = overall, diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCase.kt b/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCase.kt index 894d407..e51c38a 100644 --- a/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCase.kt +++ b/app/src/main/java/com/jdluu/flexinsight/domain/usecase/CompareRoutineSessionsUseCase.kt @@ -1,6 +1,7 @@ package com.jdluu.flexinsight.domain.usecase import com.jdluu.flexinsight.data.repository.WorkoutRepository +import com.jdluu.flexinsight.domain.calc.PersonalRecordCalculator import com.jdluu.flexinsight.domain.model.RoutineComparison import kotlinx.coroutines.flow.first import javax.inject.Inject @@ -33,8 +34,8 @@ class CompareRoutineSessionsUseCase @Inject constructor( } ?: return@forEach val recentSets = workoutRepository.getSetsByExerciseId(rex.id) val prevSets = workoutRepository.getSetsByExerciseId(prev.id) - val recentBest = recentSets.maxOfOrNull { (it.weight ?: 0.0) * (it.reps ?: 0) } ?: 0.0 - val prevBest = prevSets.maxOfOrNull { (it.weight ?: 0.0) * (it.reps ?: 0) } ?: 0.0 + val recentBest = PersonalRecordCalculator.bestSetVolume(recentSets) + val prevBest = PersonalRecordCalculator.bestSetVolume(prevSets) when { recentBest > prevBest * 1.02 -> improvements.add("${rex.name}: volume up") recentBest < prevBest * 0.98 -> regressions.add("${rex.name}: volume down") diff --git a/app/src/main/java/com/jdluu/flexinsight/domain/util/StatsCalculator.kt b/app/src/main/java/com/jdluu/flexinsight/domain/util/StatsCalculator.kt index 3428956..af13b98 100644 --- a/app/src/main/java/com/jdluu/flexinsight/domain/util/StatsCalculator.kt +++ b/app/src/main/java/com/jdluu/flexinsight/domain/util/StatsCalculator.kt @@ -1,272 +1,67 @@ package com.jdluu.flexinsight.domain.util import com.jdluu.flexinsight.data.model.* -import java.time.* -import java.time.temporal.ChronoUnit -import java.time.temporal.WeekFields -import java.util.Locale +import com.jdluu.flexinsight.domain.calc.DurationCalculator +import com.jdluu.flexinsight.domain.calc.StreakCalculator +import com.jdluu.flexinsight.domain.calc.TrainingLoadCalculator +import com.jdluu.flexinsight.domain.calc.VolumeCalculator /** - * Helper class for calculating workout statistics using java.time types. - * Contains pure functions for improved testability. + * Facade over the pure calculators in [com.jdluu.flexinsight.domain.calc]. + * Kept for source compatibility with existing call sites and tests; new code + * should call the calculators directly. */ object StatsCalculator { - /** - * Calculate total volume from a list of workouts, exercises, and sets. - */ + /** Calculate total volume from a list of workouts, exercises, and sets. */ fun calculateTotalVolume( workouts: List, allExercises: List, allSets: List - ): Double { - val exercisesByWorkout = allExercises.groupBy { it.workoutId } - val setsByExercise = allSets.groupBy { it.exerciseId } + ): Double = VolumeCalculator.totalVolume(workouts, allExercises, allSets) - return workouts.sumOf { workout -> - val exercises = exercisesByWorkout[workout.id] ?: emptyList() - exercises.sumOf { exercise: Exercise -> - val sets = setsByExercise[exercise.id] ?: emptyList() - sets.sumOf { set: com.jdluu.flexinsight.data.model.Set -> - ((set.weight ?: 0.0) * (set.reps ?: 0)).toDouble() - } - } - } - } + /** Calculate total duration in minutes. */ + fun calculateTotalDuration(workouts: List): Long = + DurationCalculator.totalDuration(workouts) - /** - * Calculate total duration in minutes. - */ - fun calculateTotalDuration(workouts: List): Long { - return workouts - .filter { it.endTime != null } - .sumOf { workout -> - val endTime = workout.endTime ?: return@sumOf 0L - (endTime - workout.startTime) / (1000 * 60) - } - } + /** Calculate current streak of consecutive days with workouts. */ + fun calculateStreak(workouts: List): Int = + StreakCalculator.currentStreak(workouts) - /** - * Calculate current streak of consecutive days with workouts. - */ - fun calculateStreak(workouts: List): Int { - if (workouts.isEmpty()) return 0 + /** Calculate longest streak of consecutive days. */ + fun calculateLongestStreak(workouts: List): Int = + StreakCalculator.longestStreak(workouts) - // Group by LocalDate to handle multiple workouts per day - val workoutDates = workouts - .map { Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() } - .distinct() - .sortedDescending() + /** Calculate volume balance across muscle groups. */ + fun calculateVolumeBalance(muscleGroupProgress: List): VolumeBalance = + VolumeCalculator.volumeBalance(muscleGroupProgress) - val today = LocalDate.now() - val yesterday = today.minusDays(1) - - if (workoutDates.isEmpty()) return 0 - - // Streak must start today or yesterday - val latestWorkout = workoutDates.first() - if (latestWorkout != today && latestWorkout != yesterday) { - return 0 - } - - var streak = 0 - var currentDate = latestWorkout - - // Check for consecutive days backwards - for (i in workoutDates.indices) { - if (workoutDates[i] == currentDate) { - streak++ - currentDate = currentDate.minusDays(1) - } else { - break - } - } - - return streak - } - - /** - * Calculate longest streak of consecutive days. - */ - fun calculateLongestStreak(workouts: List): Int { - if (workouts.isEmpty()) return 0 - - val workoutDates = workouts - .map { Instant.ofEpochMilli(it.startTime).atZone(ZoneId.systemDefault()).toLocalDate() } - .distinct() - .sorted() - - var longestStreak = 0 - var currentStreak = 0 - var lastDate: LocalDate? = null - - for (date in workoutDates) { - if (lastDate == null) { - currentStreak = 1 - lastDate = date - } else { - val daysDiff = ChronoUnit.DAYS.between(lastDate, date) - if (daysDiff == 1L) { - currentStreak++ - } else { - longestStreak = maxOf(longestStreak, currentStreak) - currentStreak = 1 - } - lastDate = date - } - } - - return maxOf(longestStreak, currentStreak) - } - - /** - * Calculate volume balance across muscle groups. - */ - fun calculateVolumeBalance(muscleGroupProgress: List): VolumeBalance { - val pushGroups = setOf("Chest", "Shoulders", "Triceps") - val pullGroups = setOf("Back", "Biceps") - val legsGroups = setOf("Legs", "Quads", "Hamstrings", "Glutes", "Calves") - val cardioGroups = setOf("Cardio") - - var pushVolume = 0.0 - var pullVolume = 0.0 - var legsVolume = 0.0 - var cardioVolume = 0.0 - - muscleGroupProgress.forEach { progress -> - val group = progress.muscleGroup - when { - pushGroups.any { group.contains(it, ignoreCase = true) } -> pushVolume += progress.volume - pullGroups.any { group.contains(it, ignoreCase = true) } -> pullVolume += progress.volume - legsGroups.any { group.contains(it, ignoreCase = true) } -> legsVolume += progress.volume - cardioGroups.any { group.contains(it, ignoreCase = true) } -> cardioVolume += progress.volume - } - } - - val totalVolume = pushVolume + pullVolume + legsVolume + cardioVolume - - return if (totalVolume > 0) { - VolumeBalance( - push = (pushVolume / totalVolume).toFloat(), - pull = (pullVolume / totalVolume).toFloat(), - legs = (legsVolume / totalVolume).toFloat(), - cardio = (cardioVolume / totalVolume).toFloat() - ) - } else { - VolumeBalance(0.25f, 0.25f, 0.25f, 0.25f) - } - } - - /** - * Calculate daily duration trend. - */ + /** Calculate daily duration trend. */ fun calculateDurationTrend( workouts: List, startDate: Long, endDate: Long - ): List { - val dayGroups = mutableMapOf>() - - // Initialize map with empty lists - DayOfWeek.values().forEach { day -> - dayGroups[day] = mutableListOf() - } - - workouts.forEach { workout -> - if (workout.endTime != null && workout.startTime >= startDate && workout.startTime <= endDate) { - val workoutDate = Instant.ofEpochMilli(workout.startTime) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - val dayOfWeek = workoutDate.dayOfWeek - val duration = (workout.endTime - workout.startTime) / (1000 * 60) - - dayGroups.getOrPut(dayOfWeek) { mutableListOf() }.add(duration) - } - } - - // Map to display order M, T, W, T, F, S, S - val displayDays = listOf( - DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY - ) - - return displayDays.map { day -> - val durations = dayGroups[day] ?: emptyList() - val avg = if (durations.isNotEmpty()) durations.average().toLong() else 0L - DailyDurationData( - dayOfWeek = day.name.take(1), // M, T, W... - averageDuration = avg - ) - } - } + ): List = DurationCalculator.durationTrend(workouts, startDate, endDate) - /** - * Get start of the day timestamp. - */ - fun getStartOfDay(timestamp: Long): Long { - return Instant.ofEpochMilli(timestamp) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .atStartOfDay(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - } + /** Get start of the day timestamp. */ + fun getStartOfDay(timestamp: Long): Long = DurationCalculator.startOfDay(timestamp) - /** - * Get end of the day timestamp. - */ - fun getEndOfDay(timestamp: Long): Long { - return Instant.ofEpochMilli(timestamp) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .atTime(LocalTime.MAX) - .atZone(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - } + /** Get end of the day timestamp. */ + fun getEndOfDay(timestamp: Long): Long = DurationCalculator.endOfDay(timestamp) - /** - * Calculate volume percentage change. - */ - fun calculateVolumeChange(currentVolume: Double, previousVolume: Double): Double { - return if (previousVolume > 0) { - ((currentVolume - previousVolume) / previousVolume) * 100.0 - } else { - if (currentVolume > 0) 100.0 else 0.0 - } - } + /** Calculate volume percentage change. */ + fun calculateVolumeChange(currentVolume: Double, previousVolume: Double): Double = + VolumeCalculator.changePercent(currentVolume, previousVolume) - /** - * Calculate weekly goal status. - */ - fun calculateGoalStatus(completed: Int, target: Int): String { - return when { - completed >= target -> "On Track" - completed >= target * 0.7 -> "On Track" - completed >= target * 0.5 -> "Behind" - else -> "Behind" - } - } + /** Calculate weekly goal status. */ + fun calculateGoalStatus(completed: Int, target: Int): String = + TrainingLoadCalculator.goalStatus(completed, target) - /** - * Calculate relative intensity (HI/MD/LO) based on average volume. - */ - fun calculateRelativeIntensity(volume: Double, averageVolume: Double): String { - return when { - volume >= averageVolume * 1.5 -> "HI" - volume >= averageVolume * 0.7 -> "MD" - else -> "LO" - } - } + /** Calculate relative intensity (HI/MD/LO) based on average volume. */ + fun calculateRelativeIntensity(volume: Double, averageVolume: Double): String = + VolumeCalculator.relativeIntensity(volume, averageVolume) - /** - * Calculate absolute intensity based on total volume. - */ - fun calculateAbsoluteIntensity(totalVolume: Double): String { - return when { - totalVolume > 5000 -> "High Intensity" - totalVolume > 2000 -> "Medium Intensity" - else -> "Aerobic" - } - } + /** Calculate absolute intensity based on total volume. */ + fun calculateAbsoluteIntensity(totalVolume: Double): String = + VolumeCalculator.absoluteIntensity(totalVolume) } diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/screens/dashboard/parts/DashboardStats.kt b/app/src/main/java/com/jdluu/flexinsight/ui/screens/dashboard/parts/DashboardStats.kt index 26efc43..2768efc 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/screens/dashboard/parts/DashboardStats.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/screens/dashboard/parts/DashboardStats.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.jdluu.flexinsight.data.model.MuscleGroupProgress import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.domain.calc.StreakCalculator +import com.jdluu.flexinsight.domain.calc.VolumeCalculator import com.jdluu.flexinsight.ui.theme.OrangeAccent import com.jdluu.flexinsight.ui.theme.Primary import com.jdluu.flexinsight.ui.theme.SurfaceCard @@ -78,7 +80,7 @@ fun StreakIndicator(streak: Int = 0) { .background(MaterialTheme.colorScheme.outlineVariant) ) { // Progress bar based on streak (max 7 days for visual) - val progress = (streak.coerceAtMost(7) / 7f).coerceIn(0f, 1f) + val progress = StreakCalculator.barProgress(streak) Box( modifier = Modifier .fillMaxHeight() @@ -155,7 +157,7 @@ fun WeeklyProgressSection( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom ) { - val totalVolume = progress.sumOf { it.totalVolume } + val totalVolume = VolumeCalculator.sumWeeklyVolume(progress) Text( text = formatVolume(totalVolume), fontSize = 32.sp, @@ -218,14 +220,10 @@ fun WeeklyProgressSection( modifier = Modifier.padding(vertical = 8.dp) ) } else { - val totalVolume = progress.sumOf { it.totalVolume } + val totalVolume = VolumeCalculator.sumWeeklyVolume(progress) muscleGroupProgress.forEach { muscleGroup -> // Calculate percentage of total volume - val percentage = if (totalVolume > 0) { - ((muscleGroup.volume / totalVolume) * 100).toInt() - } else { - 0 - } + val percentage = VolumeCalculator.sharePercent(muscleGroup.volume, totalVolume) val icon = when (muscleGroup.muscleGroup.lowercase()) { "chest" -> Icons.Default.AccessibilityNew "back" -> Icons.Default.GridView diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/screens/history/parts/HistoryStats.kt b/app/src/main/java/com/jdluu/flexinsight/ui/screens/history/parts/HistoryStats.kt index b31dbe1..d9f62fb 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/screens/history/parts/HistoryStats.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/screens/history/parts/HistoryStats.kt @@ -24,6 +24,7 @@ import com.jdluu.flexinsight.data.model.MuscleGroupProgress import com.jdluu.flexinsight.data.model.VolumeTrend import com.jdluu.flexinsight.data.model.WeeklyVolumeData import com.jdluu.flexinsight.data.model.WorkoutStats +import com.jdluu.flexinsight.domain.calc.VolumeCalculator import com.jdluu.flexinsight.ui.theme.Primary import com.jdluu.flexinsight.ui.theme.SurfaceCardAlt import com.jdluu.flexinsight.ui.theme.TextSecondary @@ -37,11 +38,7 @@ fun StatsGrid( useMetric: Boolean = false ) { val avgVolumeConverted = UnitConverter.convertVolume(avgVolume.toDouble(), useMetric) - val avgVolumeFormatted = if (avgVolumeConverted >= 1000) { - "${(avgVolumeConverted / 1000).toInt()}k" - } else { - avgVolumeConverted.toInt().toString() - } + val avgVolumeFormatted = VolumeCalculator.compactThousandsLabel(avgVolumeConverted) val unitLabel = UnitConverter.getWeightUnit(useMetric) Row( diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/screens/workoutdetail/parts/WorkoutStats.kt b/app/src/main/java/com/jdluu/flexinsight/ui/screens/workoutdetail/parts/WorkoutStats.kt index 03e702d..91d6fbd 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/screens/workoutdetail/parts/WorkoutStats.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/screens/workoutdetail/parts/WorkoutStats.kt @@ -19,6 +19,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.jdluu.flexinsight.data.model.SingleWorkoutStats +import com.jdluu.flexinsight.domain.calc.DurationCalculator +import com.jdluu.flexinsight.domain.calc.PersonalRecordCalculator import com.jdluu.flexinsight.ui.theme.Primary import com.jdluu.flexinsight.ui.theme.SurfaceCard import com.jdluu.flexinsight.ui.theme.TextSecondary @@ -31,17 +33,7 @@ fun WorkoutStatsCard( totalReps: Int, useMetric: Boolean = false ) { - val durationText = if (stats?.durationMinutes != null && stats.durationMinutes > 0) { - val hours = stats.durationMinutes / 60 - val minutes = stats.durationMinutes % 60 - if (hours > 0) { - "${hours}h ${minutes}m" - } else { - "${minutes}m" - } - } else { - "0m" - } + val durationText = stats?.durationMinutes?.let(DurationCalculator::durationLabel) ?: "0m" val volumeText = if (stats?.totalVolume != null && stats.totalVolume > 0) { UnitConverter.formatVolumeWithCommas(stats.totalVolume, useMetric) @@ -52,9 +44,7 @@ fun WorkoutStatsCard( val setsText = (stats?.totalSets ?: 0).toString() val repsText = totalReps.toString() - val hasPR = exercisesWithSets.any { exercise -> - exercise.sets.any { set -> set.isPersonalRecord } - } + val hasPR = PersonalRecordCalculator.hasPersonalRecord(exercisesWithSets.flatMap { it.sets }) Card( modifier = Modifier diff --git a/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModel.kt index fe724fa..95fac25 100644 --- a/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/com/jdluu/flexinsight/ui/viewmodel/DashboardViewModel.kt @@ -17,6 +17,7 @@ import com.jdluu.flexinsight.data.preferences.SyncPreferencesManager import com.jdluu.flexinsight.data.preferences.UserPreferencesManager import com.jdluu.flexinsight.data.sync.SyncCoordinator import com.jdluu.flexinsight.widget.WidgetUpdater +import com.jdluu.flexinsight.domain.calc.RecoveryScoreCalculator import com.jdluu.flexinsight.domain.model.DeloadAlert import com.jdluu.flexinsight.domain.model.TrainingLoadScore import com.jdluu.flexinsight.domain.usecase.CalculateTrainingLoadUseCase @@ -213,8 +214,7 @@ class DashboardViewModel @Inject constructor( widgetUpdater.updateFromDashboard( streak = stats.currentStreak, - recoveryScore = (muscleRecovery.values.average() * 100).toInt() - .coerceIn(0, 100), + recoveryScore = RecoveryScoreCalculator.overallRecoveryScore(muscleRecovery), nextWorkoutLabel = runCatching { repository.getPlannedWorkoutsForDay(System.currentTimeMillis()) .firstOrNull()?.name diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/calc/DurationCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/calc/DurationCalculatorTest.kt new file mode 100644 index 0000000..bfd70d7 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/calc/DurationCalculatorTest.kt @@ -0,0 +1,245 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.DailyDurationData +import com.jdluu.flexinsight.data.model.Workout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId +import java.util.TimeZone + +class DurationCalculatorTest { + + private val originalTimeZone = TimeZone.getDefault() + + @After + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + private fun pinUtc() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + private fun zone(): ZoneId = ZoneId.systemDefault() + + private fun millis(date: LocalDate, hour: Int = 10): Long = + date.atTime(hour, 0).atZone(zone()).toInstant().toEpochMilli() + + private fun workout( + id: String, + startTime: Long, + endTime: Long? = startTime + 3_600_000L + ) = Workout( + id = id, + name = "Workout $id", + startTime = startTime, + endTime = endTime, + notes = null, + routineId = null + ) + + // ---- workoutDurationMinutes ---- + + @Test + fun durationMinutes_openWorkoutIsZero() { + assertEquals(0L, DurationCalculator.workoutDurationMinutes(workout("open", 0, endTime = null))) + } + + @Test + fun durationMinutes_truncatesPartialMinutes() { + // 90 minutes and 59 seconds -> 90 + assertEquals(90L, DurationCalculator.workoutDurationMinutes(workout("w", 0, endTime = 90 * 60 * 1000L + 59_999L))) + } + + // ---- totalDuration ---- + + @Test + fun totalDuration_emptyListIsZero() { + assertEquals(0L, DurationCalculator.totalDuration(emptyList())) + } + + @Test + fun totalDuration_skipsWorkoutsWithoutEndTime() { + val open = workout("open", 0, endTime = null) + val closed = workout("closed", 0, endTime = 30 * 60 * 1000L) + + assertEquals(30L, DurationCalculator.totalDuration(listOf(open, closed))) + } + + @Test + fun totalDuration_sumsMultipleWorkouts() { + val a = workout("a", 0, endTime = 45 * 60 * 1000L) + val b = workout("b", 1000, endTime = 1000 + 75 * 60 * 1000L) + + assertEquals(120L, DurationCalculator.totalDuration(listOf(a, b))) + } + + // ---- averageDurationMinutes ---- + + @Test + fun averageDurationMinutes_emptyListIsZero() { + assertEquals(0L, DurationCalculator.averageDurationMinutes(emptyList())) + } + + @Test + fun averageDurationMinutes_allOpenWorkoutsIsZero() { + val open = workout("open", 0, endTime = null) + + assertEquals(0L, DurationCalculator.averageDurationMinutes(listOf(open))) + } + + @Test + fun averageDurationMinutes_truncatesFractionalAverage() { + val a = workout("a", 0, endTime = 61 * 60_000L) + val b = workout("b", 1000, endTime = 1000 + 60 * 60_000L) + + // avg(61, 60) = 60.5 truncated to 60 + assertEquals(60L, DurationCalculator.averageDurationMinutes(listOf(a, b))) + } + + // ---- durationLabel ---- + + @Test + fun durationLabel_nonPositiveRendersAsZero() { + assertEquals("0m", DurationCalculator.durationLabel(0)) + assertEquals("0m", DurationCalculator.durationLabel(-5)) + } + + @Test + fun durationLabel_underAnHourShowsMinutesOnly() { + assertEquals("45m", DurationCalculator.durationLabel(45)) + } + + @Test + fun durationLabel_exactHourStillShowsMinutesPart() { + assertEquals("1h 0m", DurationCalculator.durationLabel(60)) + assertEquals("1h 30m", DurationCalculator.durationLabel(90)) + } + + // ---- accountAgeDays ---- + + @Test + fun accountAgeDays_truncatesPartialDays() { + val twoAndAHalfDaysMs = (2 * 24 + 12) * 60 * 60 * 1000L + + assertEquals(2, DurationCalculator.accountAgeDays(0, twoAndAHalfDaysMs)) + } + + @Test + fun accountAgeDays_futureMemberSinceClampsToZero() { + assertEquals(0, DurationCalculator.accountAgeDays(memberSinceMs = 1000, nowMs = 0)) + } + + // ---- startOfDay / endOfDay ---- + + @Test + fun startOfDay_mapsToMidnightOfSameLocalDate() { + pinUtc() + val timestamp = java.time.Instant.parse("2026-06-15T14:30:45Z").toEpochMilli() + val expected = LocalDate.of(2026, 6, 15).atStartOfDay(zone()).toInstant().toEpochMilli() + + assertEquals(expected, DurationCalculator.startOfDay(timestamp)) + } + + @Test + fun endOfDay_isLastMillisecondOfSameLocalDate() { + pinUtc() + val timestamp = millis(LocalDate.of(2026, 6, 15), hour = 18) + val start = DurationCalculator.startOfDay(timestamp) + + assertEquals(start + 86_399_999L, DurationCalculator.endOfDay(timestamp)) + } + + // ---- durationTrend ---- + + @Test + fun durationTrend_returnsSixEntriesMondayThroughSaturday() { + val result = DurationCalculator.durationTrend(emptyList(), 0, Long.MAX_VALUE) + + assertEquals(listOf("M", "T", "W", "T", "F", "S"), result.map { it.dayOfWeek }) + } + + @Test + fun durationTrend_sundayWorkoutsAreDroppedFromOutput() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + val sunday = LocalDate.of(2026, 1, 11) + + val result = DurationCalculator.durationTrend( + listOf(workout("sun", millis(sunday), millis(sunday, hour = 11))), + millis(monday), + millis(sunday, hour = 23) + ) + + assertEquals((1..6).map { 0L }, result.map { it.averageDuration }) + } + + @Test + fun durationTrend_averagesPerWeekdayAndTruncatesFractionalMinutes() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + val wednesday = LocalDate.of(2026, 1, 7) + val workouts = listOf( + workout("m1", millis(monday, hour = 9), millis(monday, hour = 9) + 61 * 60_000L), + workout("m2", millis(monday, hour = 15), millis(monday, hour = 15) + 60 * 60_000L), + workout("w1", millis(wednesday, hour = 12), millis(wednesday, hour = 13)) + ) + + val result = DurationCalculator.durationTrend( + workouts, + millis(monday, hour = 0), + millis(wednesday, hour = 23) + ) + + assertEquals( + listOf( + DailyDurationData("M", 60L), + DailyDurationData("T", 0L), + DailyDurationData("W", 60L), + DailyDurationData("T", 0L), + DailyDurationData("F", 0L), + DailyDurationData("S", 0L) + ), + result + ) + } + + @Test + fun durationTrend_windowBoundsAreInclusive() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + val wednesday = LocalDate.of(2026, 1, 7) + val workouts = listOf( + workout("at-start", millis(monday, hour = 6), millis(monday, hour = 7)), + workout("at-end", millis(wednesday, hour = 22), millis(wednesday, hour = 23)), + workout("before-window", millis(monday.minusDays(1)), millis(monday.minusDays(1), hour = 12)), + workout("after-window", millis(wednesday.plusDays(1)), millis(wednesday.plusDays(1), hour = 12)) + ) + + val result = DurationCalculator.durationTrend( + workouts, + millis(monday, hour = 0), + millis(wednesday, hour = 23) + ) + + assertEquals(60L, result[0].averageDuration) + assertEquals(0L, result[1].averageDuration) + assertEquals(60L, result[2].averageDuration) + } + + @Test + fun durationTrend_excludesWorkoutsMissingEndTime() { + pinUtc() + val monday = LocalDate.of(2026, 1, 5) + + val result = DurationCalculator.durationTrend( + listOf(workout("open-ended", millis(monday), endTime = null)), + millis(monday, hour = 0), + millis(monday, hour = 23) + ) + + assertEquals(0L, result[0].averageDuration) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/calc/PersonalRecordCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/calc/PersonalRecordCalculatorTest.kt new file mode 100644 index 0000000..6bf8655 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/calc/PersonalRecordCalculatorTest.kt @@ -0,0 +1,83 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.Set +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PersonalRecordCalculatorTest { + + private fun set( + id: String, + weight: Double?, + reps: Int?, + isPersonalRecord: Boolean = false + ) = Set( + id = id, + exerciseId = "e1", + number = 1, + weight = weight, + reps = reps, + rpe = null, + distance = null, + duration = null, + restDuration = null, + notes = null, + isPersonalRecord = isPersonalRecord + ) + + // ---- bestSetVolume ---- + + @Test + fun bestSetVolume_picksHeaviestSetByTonnage() { + val sets = listOf( + set("s1", weight = 100.0, reps = 5), // 500 + set("s2", weight = 60.0, reps = 12), // 720 + set("s3", weight = 140.0, reps = 1) // 140 + ) + + assertEquals(720.0, PersonalRecordCalculator.bestSetVolume(sets), 0.0001) + } + + @Test + fun bestSetVolume_emptyInputIsZero() { + assertEquals(0.0, PersonalRecordCalculator.bestSetVolume(emptyList()), 0.0001) + } + + @Test + fun bestSetVolume_nullFieldsContributeZero() { + val sets = listOf(set("s1", weight = null, reps = 10), set("s2", weight = 50.0, reps = null)) + + assertEquals(0.0, PersonalRecordCalculator.bestSetVolume(sets), 0.0001) + } + + @Test + fun bestSetVolume_allZeroSetsIsZeroNotNegative() { + assertEquals(0.0, PersonalRecordCalculator.bestSetVolume(listOf(set("s1", 0.0, 0))), 0.0001) + } + + // ---- hasPersonalRecord ---- + + @Test + fun hasPersonalRecord_trueWhenAnySetFlagged() { + val sets = listOf( + set("s1", 50.0, 10), + set("s2", 80.0, 6, isPersonalRecord = true) + ) + + assertTrue(PersonalRecordCalculator.hasPersonalRecord(sets)) + } + + @Test + fun hasPersonalRecord_falseWhenNoSetFlagged() { + assertFalse( + PersonalRecordCalculator.hasPersonalRecord(listOf(set("s1", 50.0, 10))) + ) + } + + @Test + fun hasPersonalRecord_falseForEmptyInput() { + assertFalse(PersonalRecordCalculator.hasPersonalRecord(emptyList())) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/calc/RecoveryScoreCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/calc/RecoveryScoreCalculatorTest.kt new file mode 100644 index 0000000..5907702 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/calc/RecoveryScoreCalculatorTest.kt @@ -0,0 +1,96 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.MuscleGroup +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecoveryScoreCalculatorTest { + + private val hourMs = 60L * 60 * 1000 + + // ---- recoveryFraction ---- + + @Test + fun recoveryFraction_halfWindowIsHalf() { + assertEquals(0.5f, RecoveryScoreCalculator.recoveryFraction(nowMs = 36 * hourMs, lastTrainedMs = 0), 0.0001f) + } + + @Test + fun recoveryFraction_clampsToOneBeyondWindow() { + assertEquals(1.0f, RecoveryScoreCalculator.recoveryFraction(nowMs = 100 * hourMs, lastTrainedMs = 0), 0.0001f) + } + + @Test + fun recoveryFraction_futureTimestampClampsToZero() { + assertEquals(0.0f, RecoveryScoreCalculator.recoveryFraction(nowMs = 0, lastTrainedMs = hourMs), 0.0001f) + } + + @Test + fun recoveryFraction_customWindowScales() { + // 12 hours elapsed against a 24-hour window -> 0.5 + assertEquals( + 0.5f, + RecoveryScoreCalculator.recoveryFraction(nowMs = 12 * hourMs, lastTrainedMs = 0, recoveryWindowMs = 24 * hourMs), + 0.0001f + ) + } + + // ---- muscleRecoveryStatus ---- + + @Test + fun status_coversEveryMuscleGroup() { + val status = RecoveryScoreCalculator.muscleRecoveryStatus(nowMs = 0, lastTrainedByGroup = emptyMap()) + + assertEquals(MuscleGroup.values().toSet(), status.keys) + } + + @Test + fun status_untrainedGroupsAreFullyRecovered() { + val status = RecoveryScoreCalculator.muscleRecoveryStatus(nowMs = 0, lastTrainedByGroup = emptyMap()) + + MuscleGroup.values().forEach { group -> + assertEquals(1.0f, status.getValue(group), 0.0001f) + } + } + + @Test + fun status_trainedGroupsReflectElapsedWindow() { + val now = 72 * hourMs + val status = RecoveryScoreCalculator.muscleRecoveryStatus( + nowMs = now, + lastTrainedByGroup = mapOf( + MuscleGroup.CHEST to now - 36 * hourMs, // half recovered + MuscleGroup.BACK to now - 80 * hourMs // beyond window, clamped + ) + ) + + assertEquals(0.5f, status.getValue(MuscleGroup.CHEST), 0.0001f) + assertEquals(1.0f, status.getValue(MuscleGroup.BACK), 0.0001f) + assertEquals(1.0f, status.getValue(MuscleGroup.LEGS), 0.0001f) + } + + // ---- overallRecoveryScore ---- + + @Test + fun overall_truncatesAverageTimesHundred() { + // avg(0.5, 0.75) = 0.625 -> 62 + assertEquals( + 62, + RecoveryScoreCalculator.overallRecoveryScore( + mapOf(MuscleGroup.CHEST to 0.5f, MuscleGroup.BACK to 0.75f) + ) + ) + } + + @Test + fun overall_emptyMapYieldsZero() { + assertEquals(0, RecoveryScoreCalculator.overallRecoveryScore(emptyMap())) + } + + @Test + fun overall_fullRecoveryYieldsHundred() { + val allRecovered = MuscleGroup.values().associateWith { 1.0f } + + assertEquals(100, RecoveryScoreCalculator.overallRecoveryScore(allRecovered)) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/calc/StreakCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/calc/StreakCalculatorTest.kt new file mode 100644 index 0000000..0bd33c4 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/calc/StreakCalculatorTest.kt @@ -0,0 +1,167 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.Workout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId +import java.util.TimeZone + +class StreakCalculatorTest { + + private val originalTimeZone = TimeZone.getDefault() + + @After + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + private fun pinUtc() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + private fun millis(date: LocalDate, hour: Int = 10): Long = + date.atTime(hour, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + private fun workout(id: String, startTime: Long) = Workout( + id = id, + name = "Workout $id", + startTime = startTime, + endTime = startTime + 3_600_000L, + notes = null, + routineId = null + ) + + // ---- currentStreak ---- + + @Test + fun streak_emptyHistoryIsZero() { + assertEquals(0, StreakCalculator.currentStreak(emptyList())) + } + + @Test + fun streak_staleLatestDayBreaksStreakImmediately() { + pinUtc() + val old = LocalDate.now().minusDays(5) + + assertEquals(0, StreakCalculator.currentStreak(listOf(workout("old", millis(old))))) + } + + @Test + fun streak_countsBackThroughConsecutiveDaysFromToday() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today, today.minusDays(1), today.minusDays(2)) + + assertEquals(3, StreakCalculator.currentStreak(days.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun streak_startedYesterdayStillCountsWhenTodayHasNoWorkout() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today.minusDays(1), today.minusDays(2)) + + assertEquals(2, StreakCalculator.currentStreak(days.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun streak_multipleSessionsPerDayCollapseToOneDate() { + pinUtc() + val today = LocalDate.now() + val sameDayTwice = listOf( + workout("am", millis(today, hour = 8)), + workout("pm", millis(today, hour = 20)) + ) + + assertEquals(1, StreakCalculator.currentStreak(sameDayTwice)) + } + + @Test + fun streak_missedDayTruncatesRun() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today, today.minusDays(1), today.minusDays(3), today.minusDays(4)) + + assertEquals(2, StreakCalculator.currentStreak(days.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun streak_handlesUnsortedInput() { + pinUtc() + val today = LocalDate.now() + val days = listOf(today.minusDays(2), today, today.minusDays(1)) + + assertEquals(3, StreakCalculator.currentStreak(days.map { workout(it.toString(), millis(it)) })) + } + + // ---- longestStreak ---- + + @Test + fun longestStreak_emptyHistoryIsZero() { + assertEquals(0, StreakCalculator.longestStreak(emptyList())) + } + + @Test + fun longestStreak_singleWorkoutIsOne() { + assertEquals(1, StreakCalculator.longestStreak(listOf(workout("w", 0)))) + } + + @Test + fun longestStreak_findsLongestRunAnywhereInHistory() { + pinUtc() + val base = LocalDate.of(2026, 1, 5) + val run1 = (0..2).map { base.plusDays(it.toLong()) } // 3-day run + val run2 = (0..4).map { base.plusDays(10 + it.toLong()) } // 5-day run + val dates = run1 + run2 + + assertEquals(5, StreakCalculator.longestStreak(dates.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun longestStreak_fullyConsecutiveHistoryReturnsEntireLength() { + pinUtc() + val base = LocalDate.of(2026, 3, 1) + val dates = (0..6).map { base.plusDays(it.toLong()) } + + assertEquals(7, StreakCalculator.longestStreak(dates.map { workout(it.toString(), millis(it)) })) + } + + @Test + fun longestStreak_sameDayDuplicatesDoNotInflateStreak() { + pinUtc() + val base = LocalDate.of(2026, 2, 1) + val doubled = listOf(base, base, base.plusDays(1)) + + assertEquals(2, StreakCalculator.longestStreak(doubled.map { workout(it.toString(), millis(it)) })) + } + + // ---- barProgress ---- + + @Test + fun barProgress_zeroStreakIsEmptyBar() { + assertEquals(0f, StreakCalculator.barProgress(0), 0.0001f) + } + + @Test + fun barProgress_partialStreakIsFractional() { + assertEquals(3f / 7f, StreakCalculator.barProgress(3), 0.0001f) + } + + @Test + fun barProgress_capsAtSevenDays() { + assertEquals(1f, StreakCalculator.barProgress(7), 0.0001f) + assertEquals(1f, StreakCalculator.barProgress(30), 0.0001f) + } + + @Test + fun barProgress_negativeStreakClampsToZero() { + assertEquals(0f, StreakCalculator.barProgress(-2), 0.0001f) + } + + @Test + fun barProgress_customMaxScalesLinearly() { + assertEquals(0.5f, StreakCalculator.barProgress(5, maxDisplayDays = 10), 0.0001f) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/calc/TrainingLoadCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/calc/TrainingLoadCalculatorTest.kt new file mode 100644 index 0000000..0eb48d0 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/calc/TrainingLoadCalculatorTest.kt @@ -0,0 +1,192 @@ +package com.jdluu.flexinsight.domain.calc + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class TrainingLoadCalculatorTest { + + private val originalLocale = Locale.getDefault() + + @Before + fun setUp() { + Locale.setDefault(Locale.US) + } + + @After + fun tearDown() { + Locale.setDefault(originalLocale) + } + + // ---- hevyVolumeScore ---- + + @Test + fun volumeScore_derivedFromGoalCompletion() { + assertEquals(100, TrainingLoadCalculator.hevyVolumeScore(goalCompleted = 4, goalTarget = 4, totalWorkouts = null)) + } + + @Test + fun volumeScore_truncatesFractionalPercentage() { + // 2/3 of goal = 66.67%, toInt() truncates to 66 + assertEquals(66, TrainingLoadCalculator.hevyVolumeScore(goalCompleted = 2, goalTarget = 3, totalWorkouts = null)) + } + + @Test + fun volumeScore_coercedTo100WhenGoalExceeded() { + assertEquals(100, TrainingLoadCalculator.hevyVolumeScore(goalCompleted = 10, goalTarget = 5, totalWorkouts = null)) + } + + @Test + fun volumeScore_coercedTo0WhenNothingCompleted() { + assertEquals(0, TrainingLoadCalculator.hevyVolumeScore(goalCompleted = 0, goalTarget = 5, totalWorkouts = null)) + } + + @Test + fun volumeScore_zeroTargetSkipsGoalBranchAndUsesWorkoutCountFallback() { + assertEquals(65, TrainingLoadCalculator.hevyVolumeScore(goalCompleted = 0, goalTarget = 0, totalWorkouts = 3)) + } + + @Test + fun volumeScore_nullGoalWithWorkoutsYields65() { + assertEquals(65, TrainingLoadCalculator.hevyVolumeScore(null, null, totalWorkouts = 1)) + } + + @Test + fun volumeScore_noDataAtAllYieldsFloorOf30() { + assertEquals(30, TrainingLoadCalculator.hevyVolumeScore(null, null, null)) + assertEquals(30, TrainingLoadCalculator.hevyVolumeScore(null, null, totalWorkouts = 0)) + } + + // ---- cardioScore ---- + + @Test + fun cardioScore_is15PointsPerSession() { + assertEquals(60, TrainingLoadCalculator.cardioScore(4)) + assertEquals(0, TrainingLoadCalculator.cardioScore(0)) + } + + @Test + fun cardioScore_cappedAt100() { + // 7 sessions would naively be 105; capped at 100 + assertEquals(100, TrainingLoadCalculator.cardioScore(7)) + } + + // ---- sleepScore ---- + + @Test + fun sleepScore_missingDataIsNeutral50() { + assertEquals(50, TrainingLoadCalculator.sleepScore(null)) + } + + @Test + fun sleepScore_sevenHoursOrMoreScores90() { + assertEquals(90, TrainingLoadCalculator.sleepScore(7.0)) + assertEquals(90, TrainingLoadCalculator.sleepScore(8.25)) + } + + @Test + fun sleepScore_sixToSevenHoursScores70() { + assertEquals(70, TrainingLoadCalculator.sleepScore(6.0)) + assertEquals(70, TrainingLoadCalculator.sleepScore(6.5)) + } + + @Test + fun sleepScore_fiveToSixHoursScores50() { + assertEquals(50, TrainingLoadCalculator.sleepScore(5.0)) + assertEquals(50, TrainingLoadCalculator.sleepScore(5.5)) + } + + @Test + fun sleepScore_belowFiveHoursScores25() { + assertEquals(25, TrainingLoadCalculator.sleepScore(4.99)) + } + + // ---- overall ---- + + @Test + fun overall_blendsWithFixedWeightsAndTruncates() { + // 100*0.5 + 60*0.2 + 90*0.3 = 89 + assertEquals(89, TrainingLoadCalculator.overall(volumeScore = 100, cardioScore = 60, sleepScore = 90)) + // 65*0.5 + 15*0.2 + 70*0.3 = 56.5 -> 56 + assertEquals(56, TrainingLoadCalculator.overall(volumeScore = 65, cardioScore = 15, sleepScore = 70)) + // 30*0.5 + 0*0.2 + 50*0.3 = 30 + assertEquals(30, TrainingLoadCalculator.overall(volumeScore = 30, cardioScore = 0, sleepScore = 50)) + } + + @Test + fun overall_isClampedToHundredRange() { + assertEquals(100, TrainingLoadCalculator.overall(100, 100, 100)) + assertEquals(0, TrainingLoadCalculator.overall(0, 0, 0)) + } + + // ---- label ---- + + @Test + fun label_thresholds() { + assertEquals("High load", TrainingLoadCalculator.label(80)) + assertEquals("High load", TrainingLoadCalculator.label(100)) + assertEquals("Moderate load", TrainingLoadCalculator.label(79)) + assertEquals("Moderate load", TrainingLoadCalculator.label(55)) + assertEquals("Recovery focus", TrainingLoadCalculator.label(54)) + assertEquals("Recovery focus", TrainingLoadCalculator.label(0)) + } + + // ---- detail ---- + + @Test + fun detail_omitsHealthPartsWhenNoData() { + assertEquals( + "Hevy volume 65/100", + TrainingLoadCalculator.detail(65, hasHealthData = false, 7.5, 5000L, 2) + ) + } + + @Test + fun detail_includesSleepStepsAndCardioWhenPresent() { + assertEquals( + "Hevy volume 60/100, sleep 7.5h, steps 5000, 2 cardio sessions", + TrainingLoadCalculator.detail(60, hasHealthData = true, 7.5, 5000L, 2) + ) + } + + @Test + fun detail_restingHeartRateAloneCountsAsDataButNeverAppears() { + // hasHealthData=true with all optional segments null/zero renders the bare prefix + assertEquals( + "Hevy volume 65/100", + TrainingLoadCalculator.detail(65, hasHealthData = true, null, null, 0) + ) + } + + @Test + fun detail_omitsCardioSegmentWhenZeroSessions() { + assertEquals( + "Hevy volume 20/100, sleep 8.0h, steps 12000", + TrainingLoadCalculator.detail(20, hasHealthData = true, 8.0, 12000L, 0) + ) + } + + // ---- goalStatus ---- + + @Test + fun goalStatus_atTargetIsOnTrack() { + assertEquals("On Track", TrainingLoadCalculator.goalStatus(completed = 5, target = 5)) + } + + @Test + fun goalStatus_exactlySeventyPercentIsOnTrack() { + assertEquals("On Track", TrainingLoadCalculator.goalStatus(completed = 7, target = 10)) + } + + @Test + fun goalStatus_betweenFiftyAndSeventyPercentIsBehind() { + assertEquals("Behind", TrainingLoadCalculator.goalStatus(completed = 6, target = 10)) + } + + @Test + fun goalStatus_belowFiftyPercentIsBehind() { + assertEquals("Behind", TrainingLoadCalculator.goalStatus(completed = 0, target = 10)) + } +} diff --git a/app/src/test/java/com/jdluu/flexinsight/domain/calc/VolumeCalculatorTest.kt b/app/src/test/java/com/jdluu/flexinsight/domain/calc/VolumeCalculatorTest.kt new file mode 100644 index 0000000..8344f46 --- /dev/null +++ b/app/src/test/java/com/jdluu/flexinsight/domain/calc/VolumeCalculatorTest.kt @@ -0,0 +1,250 @@ +package com.jdluu.flexinsight.domain.calc + +import com.jdluu.flexinsight.data.model.Exercise +import com.jdluu.flexinsight.data.model.MuscleGroupProgress +import com.jdluu.flexinsight.data.model.Set +import com.jdluu.flexinsight.data.model.VolumeBalance +import com.jdluu.flexinsight.data.model.WeeklyProgress +import com.jdluu.flexinsight.data.model.Workout +import org.junit.Assert.assertEquals +import org.junit.Test + +class VolumeCalculatorTest { + + private fun workout(id: String, startTime: Long = 0) = Workout( + id = id, + name = "Workout $id", + startTime = startTime, + endTime = startTime + 3_600_000L, + notes = null, + routineId = null + ) + + private fun exercise(id: String, workoutId: String) = Exercise( + id = id, + workoutId = workoutId, + exerciseTemplateId = null, + name = "Exercise $id", + notes = null, + restDuration = null + ) + + private fun set(id: String, exerciseId: String, weight: Double?, reps: Int?) = Set( + id = id, + exerciseId = exerciseId, + number = 1, + weight = weight, + reps = reps, + rpe = null, + distance = null, + duration = null, + restDuration = null, + notes = null + ) + + // ---- setVolume / totalSetVolume ---- + + @Test + fun setVolume_nullWeightOrRepsIsZero() { + assertEquals(0.0, VolumeCalculator.setVolume(null, 10), 0.0001) + assertEquals(0.0, VolumeCalculator.setVolume(50.0, null), 0.0001) + assertEquals(0.0, VolumeCalculator.setVolume(null, null), 0.0001) + } + + @Test + fun setVolume_multipliesWeightByReps() { + assertEquals(500.0, VolumeCalculator.setVolume(50.0, 10), 0.0001) + } + + @Test + fun totalSetVolume_sumsAllSets() { + val sets = listOf(set("s1", "e1", 100.0, 10), set("s2", "e1", 25.0, 4)) + + assertEquals(1100.0, VolumeCalculator.totalSetVolume(sets), 0.0001) + } + + @Test + fun totalSetVolume_emptyListIsZero() { + assertEquals(0.0, VolumeCalculator.totalSetVolume(emptyList()), 0.0001) + } + + // ---- totalVolume ---- + + @Test + fun totalVolume_sumsAcrossWorkoutsExercisesAndSets() { + val sets = listOf( + set("s1", "e1", 100.0, 10), // 1000 + set("s2", "e2", 20.0, 5), // 100 + set("s3", "e3", 30.0, 10) // 300 + ) + val exercises = listOf(exercise("e1", "w1"), exercise("e2", "w1"), exercise("e3", "w2")) + + assertEquals( + 1400.0, + VolumeCalculator.totalVolume(listOf(workout("w1"), workout("w2")), exercises, sets), + 0.0001 + ) + } + + @Test + fun totalVolume_ignoresOrphanExercisesAndSets() { + val volume = VolumeCalculator.totalVolume( + listOf(workout("w1")), + listOf(exercise("orphan-e", "unknown-workout")), + listOf(set("orphan-s", "unknown-exercise", 999.0, 99)) + ) + + assertEquals(0.0, volume, 0.0001) + } + + @Test + fun totalVolume_emptyWorkoutsIsZero() { + val volume = VolumeCalculator.totalVolume( + emptyList(), + listOf(exercise("e1", "w1")), + listOf(set("s1", "e1", 100.0, 10)) + ) + + assertEquals(0.0, volume, 0.0001) + } + + // ---- sumWeeklyVolume ---- + + @Test + fun sumWeeklyVolume_sumsWeeklyTotals() { + val progress = listOf( + WeeklyProgress(weekStartDate = 0, totalVolume = 1200.0, workoutCount = 2, averageVolume = 600.0), + WeeklyProgress(weekStartDate = 1, totalVolume = 800.5, workoutCount = 1, averageVolume = 800.5), + WeeklyProgress(weekStartDate = 2, totalVolume = 0.0, workoutCount = 0, averageVolume = 0.0) + ) + + assertEquals(2000.5, VolumeCalculator.sumWeeklyVolume(progress), 0.0001) + } + + @Test + fun sumWeeklyVolume_emptyListIsZero() { + assertEquals(0.0, VolumeCalculator.sumWeeklyVolume(emptyList()), 0.0001) + } + + // ---- compactThousandsLabel ---- + + @Test + fun compactThousandsLabel_collapsesThousands() { + assertEquals("12k", VolumeCalculator.compactThousandsLabel(12345.0)) + assertEquals("1k", VolumeCalculator.compactThousandsLabel(1999.9)) + } + + @Test + fun compactThousandsLabel_belowThousandTruncatesToInteger() { + assertEquals("999", VolumeCalculator.compactThousandsLabel(999.9)) + assertEquals("0", VolumeCalculator.compactThousandsLabel(0.0)) + } + + // ---- sharePercent ---- + + @Test + fun sharePercent_truncatesFractionalPercent() { + // 1/3 -> 33.33 truncated to 33 + assertEquals(33, VolumeCalculator.sharePercent(1.0, 3.0)) + } + + @Test + fun sharePercent_nonPositiveTotalIsZero() { + assertEquals(0, VolumeCalculator.sharePercent(50.0, 0.0)) + assertEquals(0, VolumeCalculator.sharePercent(50.0, -10.0)) + } + + // ---- changePercent ---- + + @Test + fun changePercent_positiveDeltaYieldsPositivePercent() { + assertEquals(25.0, VolumeCalculator.changePercent(1250.0, 1000.0), 0.0001) + } + + @Test + fun changePercent_negativeDeltaYieldsNegativePercent() { + assertEquals(-40.0, VolumeCalculator.changePercent(600.0, 1000.0), 0.0001) + } + + @Test + fun changePercent_zeroPreviousWithCurrentReturns100() { + assertEquals(100.0, VolumeCalculator.changePercent(500.0, 0.0), 0.0001) + } + + @Test + fun changePercent_bothZeroReturnsZero() { + assertEquals(0.0, VolumeCalculator.changePercent(0.0, 0.0), 0.0001) + } + + // ---- relativeIntensity ---- + + @Test + fun relativeIntensity_bucketsAgainstAverage() { + assertEquals("HI", VolumeCalculator.relativeIntensity(volume = 1500.0, averageVolume = 1000.0)) + assertEquals("MD", VolumeCalculator.relativeIntensity(volume = 700.0, averageVolume = 1000.0)) + assertEquals("MD", VolumeCalculator.relativeIntensity(volume = 1499.99, averageVolume = 1000.0)) + assertEquals("LO", VolumeCalculator.relativeIntensity(volume = 699.99, averageVolume = 1000.0)) + } + + @Test + fun relativeIntensity_zeroAverageMarksEverythingHigh() { + assertEquals("HI", VolumeCalculator.relativeIntensity(volume = 0.0, averageVolume = 0.0)) + } + + // ---- absoluteIntensity ---- + + @Test + fun absoluteIntensity_thresholdsAreExclusive() { + assertEquals("High Intensity", VolumeCalculator.absoluteIntensity(5000.01)) + assertEquals("Medium Intensity", VolumeCalculator.absoluteIntensity(5000.0)) + assertEquals("Medium Intensity", VolumeCalculator.absoluteIntensity(2000.01)) + assertEquals("Aerobic", VolumeCalculator.absoluteIntensity(2000.0)) + assertEquals("Aerobic", VolumeCalculator.absoluteIntensity(0.0)) + } + + // ---- volumeBalance ---- + + @Test + fun volumeBalance_emptyInputDefaultsToQuarterSplit() { + assertEquals(VolumeBalance(0.25f, 0.25f, 0.25f, 0.25f), VolumeCalculator.volumeBalance(emptyList())) + } + + @Test + fun volumeBalance_categorizesPushPullLegsCardio() { + val progress = listOf( + MuscleGroupProgress("Chest", 300.0, 10, "MD"), + MuscleGroupProgress("Shoulders", 100.0, 5, "LO"), + MuscleGroupProgress("Back", 200.0, 8, "MD"), + MuscleGroupProgress("Biceps", 100.0, 4, "LO"), + MuscleGroupProgress("Quads", 400.0, 12, "HI"), + MuscleGroupProgress("Cardio", 50.0, 1, "LO") + ) + + val balance = VolumeCalculator.volumeBalance(progress) + + assertEquals(400.0f / 1150.0f, balance.push, 0.0001f) + assertEquals(300.0f / 1150.0f, balance.pull, 0.0001f) + assertEquals(400.0f / 1150.0f, balance.legs, 0.0001f) + assertEquals(50.0f / 1150.0f, balance.cardio, 0.0001f) + } + + @Test + fun volumeBalance_matchingIsCaseInsensitiveOnSubstrings() { + val progress = listOf( + MuscleGroupProgress("upper chest", 600.0, 6, "HI"), + MuscleGroupProgress("HAMSTRINGS", 200.0, 6, "MD") + ) + + val balance = VolumeCalculator.volumeBalance(progress) + + assertEquals(0.75f, balance.push, 0.0001f) + assertEquals(0.25f, balance.legs, 0.0001f) + } + + @Test + fun volumeBalance_unknownCategoriesFallBackToQuarterSplit() { + val progress = listOf(MuscleGroupProgress("Neck", 900.0, 3, "LO")) + + assertEquals(VolumeBalance(0.25f, 0.25f, 0.25f, 0.25f), VolumeCalculator.volumeBalance(progress)) + } +}