diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 94d8b1130..7234326af 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -608,6 +608,32 @@ data class WorkoutSession( */ fun WorkoutSession.effectiveHeaviestKgPerCable(): Float = heaviestLiftKg ?: weightPerCableKg +/** + * Resolves the safe per-cable load for history, progression, and 1RM display. + * + * A finite positive recorded summary value is authoritative. Zero, negative, and non-finite + * summary values fall back to the finite positive configured value. A deliberate zero is + * preserved only when the caller supplies the canonical bodyweight classification and the + * persisted rack context contains a positive counterweight. A WorkoutSession alone does not + * persist that classification, so counterweight must never be treated as proof. Legacy sessions + * have no summary value and therefore use their configured weight unchanged. + */ +fun WorkoutSession.displayHeaviestKgPerCable(isBodyweight: Boolean = false): Float { + val measured = heaviestLiftKg + if (measured != null && measured.isFinite()) { + if (measured > 0f) return measured + if ( + measured == 0f && + isBodyweight && + counterweightKg.isFinite() && + counterweightKg > 0f + ) { + return 0f + } + } + return weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: 0f +} + /** * Legacy multiplier metadata for explicit total/compatibility paths. * diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt index 8f01c4424..d32a926de 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCase.kt @@ -5,6 +5,7 @@ import com.devil.phoenixproject.data.repository.MAX_RECENT_EXERCISE_SESSIONS import com.devil.phoenixproject.data.repository.VelocityOneRepMaxRepository import com.devil.phoenixproject.data.repository.WorkoutRepository import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.model.displayHeaviestKgPerCable import com.devil.phoenixproject.util.OneRepMaxCalculator enum class CurrentOneRepMaxSource { @@ -19,8 +20,8 @@ data class CurrentOneRepMax( val measuredAt: Long, ) -fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(): Float? { - val load = weightPerCableKg.takeIf { it.isFinite() && it > 0f } ?: return null +fun WorkoutSession.estimatedOneRepMaxPerCableOrNull(isBodyweight: Boolean = false): Float? { + val load = displayHeaviestKgPerCable(isBodyweight).takeIf { it > 0f } ?: return null val reps = workingReps.takeIf { it > 0 } ?: totalReps.takeIf { it > 0 } ?: return null @@ -36,7 +37,11 @@ class ResolveCurrentOneRepMaxUseCase( private val assessmentRepository: AssessmentRepository, private val workoutRepository: WorkoutRepository, ) { - suspend operator fun invoke(exerciseId: String, profileId: String): CurrentOneRepMax? { + suspend operator fun invoke( + exerciseId: String, + profileId: String, + isBodyweight: Boolean = false, + ): CurrentOneRepMax? { require(exerciseId.isNotBlank()) require(profileId.isNotBlank()) @@ -79,7 +84,7 @@ class ResolveCurrentOneRepMaxUseCase( limit = MAX_RECENT_EXERCISE_SESSIONS, ).forEach { session -> if (session.exerciseId == exerciseId && session.profileId == profileId) { - val estimate = session.estimatedOneRepMaxPerCableOrNull() + val estimate = session.estimatedOneRepMaxPerCableOrNull(isBodyweight) if (estimate != null) { return CurrentOneRepMax( perCableKg = estimate, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt index 36593e8f4..ba5046d95 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailScreen.kt @@ -29,6 +29,7 @@ import com.devil.phoenixproject.data.repository.ExerciseRepository import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.WeightUnit import com.devil.phoenixproject.domain.model.WorkoutSession +import com.devil.phoenixproject.domain.model.displayHeaviestKgPerCable import com.devil.phoenixproject.domain.model.effectiveTotalVolumeKg import com.devil.phoenixproject.domain.usecase.CurrentOneRepMax import com.devil.phoenixproject.domain.usecase.CurrentOneRepMaxSource @@ -55,6 +56,7 @@ internal data class ExerciseDetailOneRepMaxRequest( val exerciseId: String, val profileId: String, val completedSessions: List, + val isBodyweight: Boolean = false, ) internal data class ExerciseDetailOneRepMaxLoadToken( @@ -81,13 +83,13 @@ internal sealed interface ExerciseDetailOneRepMaxState { internal suspend fun loadExerciseDetailOneRepMax( request: ExerciseDetailOneRepMaxRequest, gate: ExerciseDetailOneRepMaxLoadGate, - resolve: suspend (exerciseId: String, profileId: String) -> CurrentOneRepMax?, + resolve: suspend (exerciseId: String, profileId: String, isBodyweight: Boolean) -> CurrentOneRepMax?, publish: (ExerciseDetailOneRepMaxState) -> Unit, ) { val token = gate.begin(request) publish(ExerciseDetailOneRepMaxState.Loading) try { - val resolution = resolve(request.exerciseId, request.profileId) + val resolution = resolve(request.exerciseId, request.profileId, request.isBodyweight) if (gate.isCurrent(token)) { publish(ExerciseDetailOneRepMaxState.Ready(resolution)) } @@ -139,22 +141,25 @@ fun ExerciseDetailScreen( // Get exercise name — null while the repository call is in flight val unknownExerciseLabel = stringResource(Res.string.exercise_unknown) - var exerciseName by remember { mutableStateOf(null) } + var exerciseName by remember(exerciseId) { mutableStateOf(null) } + var exerciseIsBodyweight by remember(exerciseId) { mutableStateOf(false) } LaunchedEffect(exerciseId) { val exercise = viewModel.exerciseRepository.getExerciseById(exerciseId) exerciseName = exercise?.name ?: unknownExerciseLabel + exerciseIsBodyweight = exercise?.isBodyweight == true // Clear topbar title to allow dynamic title from EnhancedMainScreen viewModel.updateTopBarTitle("") } val resolveCurrentOneRepMax: ResolveCurrentOneRepMaxUseCase = koinInject() val loadGate = remember { ExerciseDetailOneRepMaxLoadGate() } - val request = remember(exerciseId, profileId, exerciseSessions) { + val request = remember(exerciseId, profileId, exerciseSessions, exerciseIsBodyweight) { profileId?.let { ExerciseDetailOneRepMaxRequest( exerciseId = exerciseId, profileId = it, completedSessions = exerciseSessions, + isBodyweight = exerciseIsBodyweight, ) } } @@ -175,9 +180,9 @@ fun ExerciseDetailScreen( ) } - val validSessionEstimatesNewestFirst = remember(exerciseSessions) { + val validSessionEstimatesNewestFirst = remember(exerciseSessions, exerciseIsBodyweight) { exerciseSessions.mapNotNull { session -> - session.estimatedOneRepMaxPerCableOrNull() + session.estimatedOneRepMaxPerCableOrNull(exerciseIsBodyweight) ?.let { estimate -> session.timestamp to estimate } } } @@ -188,10 +193,11 @@ fun ExerciseDetailScreen( validSessionEstimatesNewestFirst.getOrNull(1)?.second // Weight-over-time trend data using saved per-cable load. - val weightTrendData = remember(exerciseSessions) { + val weightTrendData = remember(exerciseSessions, exerciseIsBodyweight) { exerciseSessions.mapNotNull { session -> - if (session.weightPerCableKg > 0) { - session.timestamp to session.weightPerCableKg + val load = session.displayHeaviestKgPerCable(exerciseIsBodyweight) + if (load > 0) { + session.timestamp to load } else { null } @@ -334,6 +340,7 @@ fun ExerciseDetailScreen( session = session, weightUnit = weightUnit, formatWeight = viewModel::formatWeight, + isBodyweight = exerciseIsBodyweight, ) } } @@ -344,6 +351,7 @@ fun ExerciseDetailScreen( sessions = exerciseSessions, weightUnit = weightUnit, formatWeight = viewModel::formatWeight, + isBodyweight = exerciseIsBodyweight, ) } } @@ -659,7 +667,12 @@ private fun VolumeChartCard(sessions: List, weightUnit: WeightUn } @Composable -private fun ExerciseHistoryTable(sessions: List, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String) { +private fun ExerciseHistoryTable( + sessions: List, + weightUnit: WeightUnit, + formatWeight: (Float, WeightUnit) -> String, + isBodyweight: Boolean, +) { if (sessions.isEmpty()) { Text( "No workout history for this exercise.", @@ -737,7 +750,7 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei ) TableCell( WeightDisplayFormatter.formatDisplayWeight( - session.weightPerCableKg, + session.displayHeaviestKgPerCable(isBodyweight), null, weightUnit, ), @@ -752,7 +765,7 @@ private fun ExerciseHistoryTable(sessions: List, weightUnit: Wei Modifier.weight(1f), ) TableCell( - session.estimatedOneRepMaxPerCableOrNull() + session.estimatedOneRepMaxPerCableOrNull(isBodyweight) ?.let { formatWeight(it, weightUnit) } ?: "-", Modifier.weight(1f), @@ -796,7 +809,12 @@ private fun TableCell(text: String, modifier: Modifier = Modifier) { } @Composable -private fun SessionHistoryRow(session: WorkoutSession, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String) { +private fun SessionHistoryRow( + session: WorkoutSession, + weightUnit: WeightUnit, + formatWeight: (Float, WeightUnit) -> String, + isBodyweight: Boolean, +) { var isExpanded by remember { mutableStateOf(false) } ExpressiveCard( @@ -823,7 +841,7 @@ private fun SessionHistoryRow(session: WorkoutSession, weightUnit: WeightUnit, f color = MaterialTheme.colorScheme.onSurface, ) Text( - "${WeightDisplayFormatter.formatDisplayWeight(session.weightPerCableKg, null, weightUnit)} × ${session.workingReps} reps", + "${WeightDisplayFormatter.formatDisplayWeight(session.displayHeaviestKgPerCable(isBodyweight), null, weightUnit)} × ${session.workingReps} reps", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt index 9545b78a1..0f17fa294 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutMetricTest.kt @@ -216,6 +216,25 @@ class WorkoutSessionTest { assertEquals(30f, session.effectiveHeaviestKgPerCable()) } + @Test + fun `display heaviest load falls back from zero and invalid summary values`() { + val counterweightedSession = WorkoutSession( + weightPerCableKg = 30f, + heaviestLiftKg = 0f, + counterweightKg = 80f, + ) + val nonFiniteMeasuredSession = WorkoutSession( + weightPerCableKg = 30f, + heaviestLiftKg = Float.POSITIVE_INFINITY, + ) + + // Counterweight alone cannot prove a bodyweight set, but a screen that has the + // canonical Exercise.isBodyweight classification can preserve a real zero. + assertEquals(30f, counterweightedSession.displayHeaviestKgPerCable(isBodyweight = false)) + assertEquals(0f, counterweightedSession.displayHeaviestKgPerCable(isBodyweight = true)) + assertEquals(30f, nonFiniteMeasuredSession.displayHeaviestKgPerCable()) + } + @Test fun `effectiveTotalVolumeKg uses measured summary value when present`() { val session = WorkoutSession( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt index 18fcfeeaf..44757713b 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/usecase/ResolveCurrentOneRepMaxUseCaseTest.kt @@ -122,6 +122,57 @@ class ResolveCurrentOneRepMaxUseCaseTest { assertNull(base.copy(workingReps = -1, totalReps = -1).estimatedOneRepMaxPerCableOrNull()) } + @Test + fun `session helper uses effectiveHeaviestKgPerCable for bodyweight sessions`() { + // Bodyweight session: configured weight is 0 but effective heaviest is 40 kg + val bodyweightSession = session( + perCableKg = 0f, + workingReps = 10, + totalReps = 10, + heaviestLiftKg = 40f, + ) + val result = bodyweightSession.estimatedOneRepMaxPerCableOrNull() + assertEquals(53.3f, result!!, 0.2f) // 1RM from 40kg x 10 reps ≈ 53.3 + // The old code would return null here since weightPerCableKg=0 + + // Nominal configured load but effective heaviest is higher (weighted vest + bodyweight) + val vestSession = session( + perCableKg = 5f, + workingReps = 10, + totalReps = 10, + heaviestLiftKg = 40f, + ) + val vestResult = vestSession.estimatedOneRepMaxPerCableOrNull() + assertEquals(53.3f, vestResult!!, 0.2f) + // Old code would compute from 5f → ~6.7f, not 40f + + // Legacy session with no heaviestLiftKg falls back to configured weight + val legacySession = session(perCableKg = 50f, workingReps = 5, totalReps = 5) + val legacyResult = legacySession.estimatedOneRepMaxPerCableOrNull() + assertEquals(56.25f, legacyResult!!, 0.1f) // 1RM from 50kg x 5 reps + + // Edge case: heaviestLiftKg = 0f (measured zero) should fall back to weightPerCableKg + // matching SQL selectExerciseWeightHistory that treats nonpositive heaviestLiftKg as absent + val zeroMeasuredSession = session( + perCableKg = 50f, + workingReps = 5, + totalReps = 5, + heaviestLiftKg = 0f, + ) + val zeroMeasuredResult = zeroMeasuredSession.estimatedOneRepMaxPerCableOrNull() + assertEquals(56.25f, zeroMeasuredResult!!, 0.1f) // falls back to weightPerCableKg=50 + + // Preserve a deliberate zero only when the caller supplies the canonical exercise + // classification; machine sessions with counterweight retain the SQL fallback. + val counterweightedBodyweightSession = zeroMeasuredSession.copy(counterweightKg = 100f) + assertEquals( + 56.25f, + counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull(isBodyweight = false)!!, + 0.1f, + ) + assertNull(counterweightedBodyweightSession.estimatedOneRepMaxPerCableOrNull(isBodyweight = true)) + } + @Test fun `wrong profile and exercise at higher sources cannot block current session`() = runTest { velocity.latestPassing = velocityEstimate( @@ -249,6 +300,7 @@ class ResolveCurrentOneRepMaxUseCaseTest { profileId: String = "athlete-a", exerciseId: String = "bench", timestamp: Long = 10L, + heaviestLiftKg: Float? = null, ) = WorkoutSession( id = "$profileId-$exerciseId-$timestamp-$perCableKg", timestamp = timestamp, @@ -261,6 +313,7 @@ class ResolveCurrentOneRepMaxUseCaseTest { exerciseId = exerciseId, exerciseName = exerciseId, profileId = profileId, + heaviestLiftKg = heaviestLiftKg, ) private fun velocityEstimate( diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt index 7f3d0d093..f615da4de 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseDetailOneRepMaxLoadTest.kt @@ -10,6 +10,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -32,7 +33,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = gate, - resolve = { _, _ -> + resolve = { _, _, _ -> aStarted.complete(Unit) releaseA.await() resultA @@ -46,7 +47,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestB, gate = gate, - resolve = { _, _ -> resultB }, + resolve = { _, _, _ -> resultB }, publish = states::add, ) releaseA.complete(Unit) @@ -72,7 +73,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = gate, - resolve = { _, _ -> + resolve = { _, _, _ -> aStarted.complete(Unit) releaseA.await() error("late A failure") @@ -85,7 +86,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestB, gate = gate, - resolve = { _, _ -> resultB }, + resolve = { _, _, _ -> resultB }, publish = states::add, ) releaseA.complete(Unit) @@ -103,7 +104,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = ExerciseDetailOneRepMaxLoadGate(), - resolve = { _, _ -> + resolve = { _, _, _ -> started.complete(Unit) awaitCancellation() }, @@ -131,7 +132,7 @@ class ExerciseDetailOneRepMaxLoadTest { loadExerciseDetailOneRepMax( request = requestA, gate = ExerciseDetailOneRepMaxLoadGate(), - resolve = { _, _ -> error("resolver unavailable") }, + resolve = { _, _, _ -> error("resolver unavailable") }, publish = states::add, ) @@ -144,6 +145,23 @@ class ExerciseDetailOneRepMaxLoadTest { ) } + @Test + fun `bodyweight context is forwarded to the resolver`() = runTest { + var receivedBodyweight = false + + loadExerciseDetailOneRepMax( + request = requestA.copy(isBodyweight = true), + gate = ExerciseDetailOneRepMaxLoadGate(), + resolve = { _, _, isBodyweight -> + receivedBodyweight = isBodyweight + resultA + }, + publish = {}, + ) + + assertTrue(receivedBodyweight) + } + @Test fun `screen has one resolver path and no legacy profile or velocity read`() { val source = readProjectFile( @@ -152,7 +170,7 @@ class ExerciseDetailOneRepMaxLoadTest { assertNotNull(source) assertContains(source, "assessmentProfileId") assertContains(source, "loadExerciseDetailOneRepMax(") - assertContains(source, "estimatedOneRepMaxPerCableOrNull()") + assertContains(source, "estimatedOneRepMaxPerCableOrNull(exerciseIsBodyweight)") assertContains(source, "catch (cancellation: CancellationException)") assertContains(source, "catch (_: Exception)") assertFalse(source.contains("catch (_: Throwable)"))