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 @@ -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
Comment thread
9thLevelSoftware marked this conversation as resolved.
}

/**
* Legacy multiplier metadata for explicit total/compatibility paths.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass bodyweight context from Profile insights

When the selected exercise is bodyweight, counterweight clamps a session's effective load to zero, and no velocity or assessment result exists, ProfileViewModel.loadInsights still invokes the resolver with only (exerciseId, profileId). This new default therefore supplies false, causing the session fallback to replace the legitimate zero with the configured load and show a nonzero current 1RM on the Profile screen. Fresh evidence after the earlier bodyweight-zero thread is the unchanged two-argument call at ProfileViewModel.kt:831; pass exercise.isBodyweight there rather than silently defaulting the new parameter.

Useful? React with 👍 / 👎.

): CurrentOneRepMax? {
require(exerciseId.isNotBlank())
require(profileId.isNotBlank())

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,6 +56,7 @@ internal data class ExerciseDetailOneRepMaxRequest(
val exerciseId: String,
val profileId: String,
val completedSessions: List<WorkoutSession>,
val isBodyweight: Boolean = false,
)

internal data class ExerciseDetailOneRepMaxLoadToken(
Expand All @@ -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))
}
Expand Down Expand Up @@ -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<String?>(null) }
var exerciseName by remember(exerciseId) { mutableStateOf<String?>(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,
)
}
}
Expand All @@ -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 }
}
}
Expand All @@ -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
}
Expand Down Expand Up @@ -334,6 +340,7 @@ fun ExerciseDetailScreen(
session = session,
weightUnit = weightUnit,
formatWeight = viewModel::formatWeight,
isBodyweight = exerciseIsBodyweight,
)
}
}
Expand All @@ -344,6 +351,7 @@ fun ExerciseDetailScreen(
sessions = exerciseSessions,
weightUnit = weightUnit,
formatWeight = viewModel::formatWeight,
isBodyweight = exerciseIsBodyweight,
)
}
}
Expand Down Expand Up @@ -659,7 +667,12 @@ private fun VolumeChartCard(sessions: List<WorkoutSession>, weightUnit: WeightUn
}

@Composable
private fun ExerciseHistoryTable(sessions: List<WorkoutSession>, weightUnit: WeightUnit, formatWeight: (Float, WeightUnit) -> String) {
private fun ExerciseHistoryTable(
sessions: List<WorkoutSession>,
weightUnit: WeightUnit,
formatWeight: (Float, WeightUnit) -> String,
isBodyweight: Boolean,
) {
if (sessions.isEmpty()) {
Text(
"No workout history for this exercise.",
Expand Down Expand Up @@ -737,7 +750,7 @@ private fun ExerciseHistoryTable(sessions: List<WorkoutSession>, weightUnit: Wei
)
TableCell(
WeightDisplayFormatter.formatDisplayWeight(
session.weightPerCableKg,
session.displayHeaviestKgPerCable(isBodyweight),
null,
weightUnit,
),
Expand All @@ -752,7 +765,7 @@ private fun ExerciseHistoryTable(sessions: List<WorkoutSession>, weightUnit: Wei
Modifier.weight(1f),
)
TableCell(
session.estimatedOneRepMaxPerCableOrNull()
session.estimatedOneRepMaxPerCableOrNull(isBodyweight)
?.let { formatWeight(it, weightUnit) }
?: "-",
Modifier.weight(1f),
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -261,6 +313,7 @@ class ResolveCurrentOneRepMaxUseCaseTest {
exerciseId = exerciseId,
exerciseName = exerciseId,
profileId = profileId,
heaviestLiftKg = heaviestLiftKg,
)

private fun velocityEstimate(
Expand Down
Loading
Loading