Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
)
}

Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -243,8 +243,8 @@ class StatsRepositoryImpl @Inject constructor(
* Get planned workouts for a specific day
*/
override suspend fun getPlannedWorkoutsForDay(timestamp: Long): List<PlannedWorkout> = 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 }
Expand All @@ -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
Expand All @@ -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)
}

/**
Expand All @@ -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())
}

/**
Expand Down Expand Up @@ -362,7 +359,7 @@ class StatsRepositoryImpl @Inject constructor(
setDao.getSetsByExerciseId(exerciseId)
}

return StatsCalculator.calculateTotalVolume(workouts, allExercises, allSets)
return VolumeCalculator.totalVolume(workouts, allExercises, allSets)
}

/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand All @@ -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<MuscleGroup, Float> = 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<MuscleGroup, Long>()
Expand All @@ -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<Workout>): 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)
}
}
Original file line number Diff line number Diff line change
@@ -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<Workout>): 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<Workout>): 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<Workout>,
startDate: Long,
endDate: Long
): List<DailyDurationData> {
val dayGroups = mutableMapOf<DayOfWeek, MutableList<Long>>()

// 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
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Set>): Double =
sets.maxOfOrNull { VolumeCalculator.setVolume(it.weight, it.reps) } ?: 0.0

/** True when any set carries the personal-record flag. */
fun hasPersonalRecord(sets: List<Set>): Boolean = sets.any { it.isPersonalRecord }
}
Original file line number Diff line number Diff line change
@@ -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<MuscleGroup, Long>,
recoveryWindowMs: Long = RECOVERY_WINDOW_MS
): Map<MuscleGroup, Float> =
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<MuscleGroup, Float>): Int =
(recovery.values.average() * 100).toInt().coerceIn(0, 100)
}
Loading
Loading