diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 992120a0d..47772b793 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -210,8 +210,8 @@ sqldelight { databases { create("VitruvianDatabase") { packageName.set("com.devil.phoenixproject.database") - // Version 43 = initial schema (1) + 42 migrations (1.sqm through 42.sqm). - version = 43 + // Version 44 = initial schema (1) + 43 migrations (1.sqm through 43.sqm). + version = 44 } } } diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt index 10784eda9..c2017bd80 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt @@ -638,10 +638,73 @@ class SchemaParityTest { assertEquals(true, getTables(driver).contains("PendingProfileContextRecovery")) } + @Test + fun `migration 42 to 43 adds set_end_reason column and preserves existing rows with default`() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + buildSchemaAtVersion(driver, 42) + + // Create prerequisite rows: UserProfile, Routine, WorkoutSession + // Note: profile_id is added by manifest reconciliation, not by migration 42. + // Use columns available at schema v42 (pre-reconciliation). + driver.execute(null, "INSERT INTO UserProfile(id,name,colorIndex,createdAt,isActive) VALUES('u1','U1',0,1,1)", 0) + driver.execute(null, "INSERT INTO Routine(id,name,createdAt) VALUES('r1','R1',1)", 0) + driver.execute(null, "INSERT INTO RoutineExercise(id,routineId,exerciseName,exerciseMuscleGroup,orderIndex,weightPerCableKg) VALUES('re1','r1','Bench','Chest',0,40.0)", 0) + driver.execute(null, "INSERT INTO WorkoutSession(id,timestamp,mode,targetReps,weightPerCableKg) VALUES('s1',1,'OldSchool',10,40.0)", 0) + + // Insert a CompletedSet BEFORE migration 43 (no set_end_reason column yet) + driver.execute( + null, + """ + INSERT INTO CompletedSet (id, session_id, set_number, set_type, actual_reps, actual_weight_kg, is_pr, completed_at) + VALUES ('cs-pre-mig', 's1', 1, 'STANDARD', 8, 40.0, 0, 1000) + """.trimIndent(), + 0, + ) + + // Migrate 42 → 43 with resilient fallback (matches production behavior) + try { + VitruvianDatabase.Schema.migrate(driver, 42, 43) + } catch (_: Exception) { + applyMigrationResilient(driver, 42) + } + + assertEquals(true, columnExistsInDriver(driver, "CompletedSet", "set_end_reason")) + + // Pre-existing row must have the default value + assertEquals("TARGET_REPS_REACHED", queryScalar(driver, "SELECT set_end_reason FROM CompletedSet WHERE id = 'cs-pre-mig'")) + } + + @Test + fun `resilient migration 43 fallback adds set_end_reason when column already exists`() { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + buildSchemaAtVersion(driver, 42) + + // Simulate schema drift: column already added by heal + driver.execute(null, "ALTER TABLE CompletedSet ADD COLUMN set_end_reason TEXT NOT NULL DEFAULT 'TARGET_REPS_REACHED'", 0) + + driver.execute(null, "INSERT INTO UserProfile(id,name,colorIndex,createdAt,isActive) VALUES('u1','U1',0,1,1)", 0) + driver.execute(null, "INSERT INTO Routine(id,name,createdAt) VALUES('r1','R1',1)", 0) + driver.execute(null, "INSERT INTO RoutineExercise(id,routineId,exerciseName,exerciseMuscleGroup,orderIndex,weightPerCableKg) VALUES('re1','r1','Bench','Chest',0,40.0)", 0) + driver.execute(null, "INSERT INTO WorkoutSession(id,timestamp,mode,targetReps,weightPerCableKg) VALUES('s1',1,'OldSchool',10,40.0)", 0) + driver.execute( + null, + """ + INSERT INTO CompletedSet (id, session_id, set_number, set_type, actual_reps, actual_weight_kg, is_pr, completed_at, set_end_reason) + VALUES ('cs-resilient', 's1', 1, 'STANDARD', 8, 40.0, 0, 1000, 'STALL_FAILURE') + """.trimIndent(), + 0, + ) + + // Resilient fallback should succeed even though the column already exists + applyMigrationResilient(driver, 43) + + assertEquals("STALL_FAILURE", queryScalar(driver, "SELECT set_end_reason FROM CompletedSet WHERE id = 'cs-resilient'")) + } + // ==================== HELPERS ==================== companion object { - private const val EXPECTED_SCHEMA_VERSION = 43L + private const val EXPECTED_SCHEMA_VERSION = 44L private val CANONICAL_UUID_REGEX = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") /** diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt index d33e2adc4..72111c3b3 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepositoryTest.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject.data.repository import com.devil.phoenixproject.database.VitruvianDatabase import com.devil.phoenixproject.domain.model.CompletedSet import com.devil.phoenixproject.domain.model.PlannedSet +import com.devil.phoenixproject.domain.model.SetEndReason import com.devil.phoenixproject.domain.model.SetType import com.devil.phoenixproject.testutil.createTestDatabase import kotlin.test.assertEquals @@ -71,6 +72,37 @@ class SqlDelightCompletedSetRepositoryTest { assertEquals(2, sets.size) } + @Test + fun `saveCompletedSet round-trips setEndReason STALL_FAILURE`() = runTest { + val completed = completedSet("cset-stall", "session-1", setNumber = 1, setEndReason = SetEndReason.STALL_FAILURE) + repository.saveCompletedSet(completed) + + val loaded = repository.getCompletedSets("session-1").first { it.id == "cset-stall" } + assertEquals(SetEndReason.STALL_FAILURE, loaded.setEndReason) + } + + @Test + fun `saveCompletedSet round-trips setEndReason TARGET_REPS_REACHED default`() = runTest { + val completed = completedSet("cset-default", "session-1", setNumber = 1) + repository.saveCompletedSet(completed) + + val loaded = repository.getCompletedSets("session-1").first { it.id == "cset-default" } + assertEquals(SetEndReason.TARGET_REPS_REACHED, loaded.setEndReason) + } + + @Test + fun `saveCompletedSet round-trips all SetEndReason values`() = runTest { + for ((index, reason) in SetEndReason.entries.withIndex()) { + repository.saveCompletedSet(completedSet("cset-$index", "session-1", setNumber = index + 1, setEndReason = reason)) + } + + val loaded = repository.getCompletedSets("session-1") + assertEquals(SetEndReason.entries.size, loaded.size) + for ((index, reason) in SetEndReason.entries.withIndex()) { + assertEquals(reason, loaded[index].setEndReason, "Mismatch at index $index") + } + } + private fun plannedSet( id: String, routineExerciseId: String, @@ -89,7 +121,7 @@ class SqlDelightCompletedSetRepositoryTest { restSeconds = restSeconds, ) - private fun completedSet(id: String, sessionId: String, setNumber: Int) = CompletedSet( + private fun completedSet(id: String, sessionId: String, setNumber: Int, setEndReason: SetEndReason = SetEndReason.TARGET_REPS_REACHED) = CompletedSet( id = id, sessionId = sessionId, plannedSetId = null, @@ -100,6 +132,7 @@ class SqlDelightCompletedSetRepositoryTest { loggedRpe = null, isPr = false, completedAt = 1000L + setNumber, + setEndReason = setEndReason, ) private fun insertRoutine(id: String) { diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt index 3071ce6c5..c14da203e 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/DataBackupManagerRoutineNameTest.kt @@ -542,6 +542,7 @@ class DataBackupManagerRoutineNameTest { logged_rpe = null, is_pr = 0, completed_at = 1700000060000L, + set_end_reason = "TARGET_REPS_REACHED", ) // Export just this session @@ -650,6 +651,7 @@ class DataBackupManagerRoutineNameTest { logged_rpe = null, is_pr = 0, completed_at = 1_700_000_006_000L, + set_end_reason = "TARGET_REPS_REACHED", ) database.vitruvianDatabaseQueries.insertCompletedSetIgnore( id = "cs-row", @@ -662,6 +664,7 @@ class DataBackupManagerRoutineNameTest { logged_rpe = null, is_pr = 0, completed_at = 1_700_000_106_000L, + set_end_reason = "TARGET_REPS_REACHED", ) val result = backupManager.exportRoutine(sharedRoutineSessionId) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt index 51f71bd8f..951fe5bca 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/MigrationStatements.kt @@ -1016,5 +1016,12 @@ WHERE gs.rowid = ( SELECT id FROM UserProfile""", ) + // Migration 43: Add set_end_reason to CompletedSet (Issue #673 PR 1) + // Records why a set ended for workout history analytics. + // Mirrors 43.sqm exactly. + 43 -> listOf( + "ALTER TABLE CompletedSet ADD COLUMN set_end_reason TEXT NOT NULL DEFAULT 'TARGET_REPS_REACHED'", + ) + else -> emptyList() } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt index f8cc59202..7440040e7 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/local/SchemaManifest.kt @@ -1141,7 +1141,7 @@ internal val manifestTables: List = listOf( """.trimIndent(), ), - // CompletedSet -- migration 10, full shape (no later migrations add columns) + // CompletedSet -- migration 10, columns added by later migrations: set_end_reason (m43) SchemaTableOperation( table = "CompletedSet", createSql = """ @@ -1156,6 +1156,7 @@ internal val manifestTables: List = listOf( logged_rpe INTEGER, is_pr INTEGER NOT NULL DEFAULT 0, completed_at INTEGER NOT NULL, + set_end_reason TEXT NOT NULL DEFAULT 'TARGET_REPS_REACHED', FOREIGN KEY (session_id) REFERENCES WorkoutSession(id) ON DELETE CASCADE, FOREIGN KEY (planned_set_id) REFERENCES PlannedSet(id) ON DELETE SET NULL ) @@ -1437,6 +1438,10 @@ internal val manifestColumns: List = listOf( // ── ExternalActivity (1 column, migration 31) ────────────────────── // Migration 31: provider tombstone handling SchemaHealOperation("ExternalActivity", "deletedAt", "ALTER TABLE ExternalActivity ADD COLUMN deletedAt INTEGER"), + + // ── CompletedSet (1 column, migration 43) ────────────────────────── + // Migration 43: set-end reason for workout history analytics (Issue #673 PR 1) + SchemaHealOperation("CompletedSet", "set_end_reason", "ALTER TABLE CompletedSet ADD COLUMN set_end_reason TEXT NOT NULL DEFAULT 'TARGET_REPS_REACHED'"), ) // ============================================================ diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepository.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepository.kt index 10a3d35a5..f6bdda67d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepository.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/repository/SqlDelightCompletedSetRepository.kt @@ -5,6 +5,7 @@ import app.cash.sqldelight.coroutines.mapToList import com.devil.phoenixproject.database.VitruvianDatabase import com.devil.phoenixproject.domain.model.CompletedSet import com.devil.phoenixproject.domain.model.PlannedSet +import com.devil.phoenixproject.domain.model.SetEndReason import com.devil.phoenixproject.domain.model.SetType import com.devil.phoenixproject.domain.model.WorkoutSession import com.devil.phoenixproject.domain.model.generateUUID @@ -54,6 +55,7 @@ class SqlDelightCompletedSetRepository(db: VitruvianDatabase) : CompletedSetRepo loggedRpe: Long?, isPr: Long, completedAt: Long, + setEndReason: String, ): CompletedSet = CompletedSet( id = id, sessionId = sessionId, @@ -65,6 +67,8 @@ class SqlDelightCompletedSetRepository(db: VitruvianDatabase) : CompletedSetRepo loggedRpe = loggedRpe?.toInt(), isPr = isPr == 1L, completedAt = completedAt, + setEndReason = runCatching { SetEndReason.valueOf(setEndReason) } + .getOrElse { SetEndReason.TARGET_REPS_REACHED }, ) // ==================== Planned Sets ==================== @@ -178,6 +182,7 @@ class SqlDelightCompletedSetRepository(db: VitruvianDatabase) : CompletedSetRepo logged_rpe = set.loggedRpe?.toLong(), is_pr = if (set.isPr) 1L else 0L, completed_at = set.completedAt, + set_end_reason = set.setEndReason.name, ) } } @@ -233,6 +238,7 @@ class SqlDelightCompletedSetRepository(db: VitruvianDatabase) : CompletedSetRepo logged_rpe = completedSet.loggedRpe?.toLong(), is_pr = if (completedSet.isPr) 1L else 0L, completed_at = completedSet.completedAt, + set_end_reason = completedSet.setEndReason.name, ) completedSet @@ -252,6 +258,7 @@ class SqlDelightCompletedSetRepository(db: VitruvianDatabase) : CompletedSetRepo logged_rpe = set.loggedRpe?.toLong(), is_pr = if (set.isPr) 1L else 0L, completed_at = set.completedAt, + set_end_reason = set.setEndReason.name, ) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/TrainingCycleModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/TrainingCycleModels.kt index 67a6c7190..7903a4087 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/TrainingCycleModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/TrainingCycleModels.kt @@ -375,6 +375,25 @@ data class PlannedSet( } } +/** + * Reason a set ended. Persisted on [CompletedSet] for workout history analytics. + * Threaded through [ActiveSessionEngine.handleSetCompletion] from every call site. + */ +enum class SetEndReason { + /** Rep target reached or WORKOUT_COMPLETE machine event */ + TARGET_REPS_REACHED, + /** Stall detection auto-stop fired (velocity/deload threshold) */ + STALL_FAILURE, + /** VBT auto-end: consecutive reps above velocity-loss threshold */ + VBT_AUTO_END, + /** User manually stopped the set */ + USER_STOPPED, + /** Cable released detected by machine */ + CABLE_RELEASED, + /** Timed exercise countdown reached zero */ + TIMER_EXPIRED, +} + /** * A completed set with actual performance data. * Records what the user actually did. @@ -390,6 +409,7 @@ data class CompletedSet( val loggedRpe: Int?, val isPr: Boolean, val completedAt: Long, + val setEndReason: SetEndReason = SetEndReason.TARGET_REPS_REACHED, ) { /** * Calculate estimated 1RM using canonical hybrid formula (Brzycki ≤10 reps, Epley >10 reps). @@ -412,6 +432,7 @@ data class CompletedSet( actualWeightKg: Float, loggedRpe: Int? = null, isPr: Boolean = false, + setEndReason: SetEndReason = SetEndReason.TARGET_REPS_REACHED, ) = CompletedSet( id = id, sessionId = sessionId, @@ -423,6 +444,7 @@ data class CompletedSet( loggedRpe = loggedRpe, isPr = isPr, completedAt = currentTimeMillis(), + setEndReason = setEndReason, ) } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index da94c4146..039881662 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -47,6 +47,7 @@ import com.devil.phoenixproject.domain.model.RoutineExercise import com.devil.phoenixproject.domain.model.RoutineFlowState import com.devil.phoenixproject.domain.model.RoutineLaunchOrigin import com.devil.phoenixproject.domain.model.SetQualitySummary +import com.devil.phoenixproject.domain.model.SetEndReason import com.devil.phoenixproject.domain.model.SetType import com.devil.phoenixproject.domain.model.TrainingCycle import com.devil.phoenixproject.domain.model.UserPreferences @@ -338,7 +339,7 @@ class ActiveSessionEngine( // Issue #182: Trigger set completion immediately on WORKOUT_COMPLETE event. if (coordinator._workoutState.value is WorkoutState.Active) { Logger.d("WORKOUT_COMPLETE event received - triggering immediate set completion") - handleSetCompletion() + handleSetCompletion(SetEndReason.TARGET_REPS_REACHED) } } } @@ -457,12 +458,14 @@ class ActiveSessionEngine( coordinator.stallStartTime = currentTimeMillis() coordinator.isCurrentlyStalled = true coordinator.stallArmedByDeload = true + coordinator.autoStopReason = SetEndReason.CABLE_RELEASED Logger.d("Auto-stop stall timer STARTED via DELOAD_OCCURRED flag") } else if (coordinator.stallStartTime != null && !inGrace) { // F4: a real deload is the stronger signal — upgrade a // velocity-armed countdown so the retracting cables // (position -> 0) don't cancel it via the racked-handles check. coordinator.stallArmedByDeload = true + coordinator.autoStopReason = SetEndReason.CABLE_RELEASED } else if (inGrace) { Logger.d("DELOAD_OCCURRED ignored - in AMRAP startup grace period") } @@ -932,7 +935,12 @@ class ActiveSessionEngine( totalReps = coercedReps, isWarmupComplete = true, ) - handleSetCompletion() + // Issue #673: Preserve existing reason (e.g. TIMER_EXPIRED) if one was already set + // by a prior handleSetCompletion call that opened this bodyweight dialog. + val preservedReason = coordinator.lastSetEndReason.takeIf { + it != SetEndReason.TARGET_REPS_REACHED + } ?: SetEndReason.TARGET_REPS_REACHED + handleSetCompletion(preservedReason) } private suspend fun showBodyweightRepEntry(currentExercise: RoutineExercise) { @@ -988,6 +996,7 @@ class ActiveSessionEngine( coordinator.stallStartTime = null coordinator.isCurrentlyStalled = false coordinator.stallArmedByDeload = false + coordinator.autoStopReason = SetEndReason.STALL_FAILURE if (coordinator.autoStopStartTime == null && !coordinator.autoStopTriggered) { coordinator._autoStopState.value = AutoStopUiState() } @@ -1076,7 +1085,7 @@ class ActiveSessionEngine( coordinator._autoStopState.value = AutoStopUiState() } - handleSetCompletion() + handleSetCompletion(coordinator.autoStopReason) } // ===== Rep Processing ===== @@ -1405,7 +1414,7 @@ class ActiveSessionEngine( if (consecutiveThresholdReps >= 2 && runtime.autoEndOnVelocityLoss) { Logger.i { "VBT: Auto-ending set — $consecutiveThresholdReps consecutive reps above threshold" } - handleSetCompletion() + handleSetCompletion(SetEndReason.VBT_AUTO_END) } } else { consecutiveThresholdReps = 0 @@ -1454,7 +1463,7 @@ class ActiveSessionEngine( } if (repCounter.shouldStopWorkout()) { - handleSetCompletion() + handleSetCompletion(SetEndReason.TARGET_REPS_REACHED) } } else { resetAutoStopTimer() @@ -2685,7 +2694,7 @@ class ActiveSessionEngine( } } coordinator._timedExerciseRemainingSeconds.value = 0 - handleSetCompletion() + handleSetCompletion(SetEndReason.TIMER_EXPIRED) } return@launch @@ -3000,7 +3009,7 @@ class ActiveSessionEngine( } } coordinator._timedExerciseRemainingSeconds.value = 0 - handleSetCompletion() + handleSetCompletion(SetEndReason.TIMER_EXPIRED) } } @@ -3123,6 +3132,9 @@ class ActiveSessionEngine( // C1: Atomic compareAndSet prevents TOCTOU race — only the first caller proceeds if (!coordinator.stopWorkoutInProgress.compareAndSet(expect = false, update = true)) return + // Issue #673: Mark manual stop so CompletedSet persists USER_STOPPED + coordinator.lastSetEndReason = SetEndReason.USER_STOPPED + val shouldExitToIdle = exitingWorkout coordinator._weightAdjustmentRecommendation.value = null @@ -3271,6 +3283,7 @@ class ActiveSessionEngine( loggedRpe = coordinator._currentSetRpe.value, isPr = false, completedAt = currentTimeMillis(), + setEndReason = coordinator.lastSetEndReason, ) completedSetRepository.saveCompletedSet(completedSet) Logger.d("Saved CompletedSet (manual stop): set #$setIndex, ${repCount.workingReps} reps${if (matchedPlannedSetId != null) " (linked to PlannedSet)" else ""}") @@ -3359,7 +3372,7 @@ class ActiveSessionEngine( Logger.d { "stopAndReturnToSetReady: Issue #320 - workingReps=${coordinator._repCount.value.workingReps} > 0, routing through handleSetCompletion to save reps and advance" } // Release stop guard before delegating — handleSetCompletion uses its own atomic guard (setCompletionInProgress) coordinator.stopWorkoutInProgress.value = false - handleSetCompletion() + handleSetCompletion(SetEndReason.USER_STOPPED) return } @@ -3855,6 +3868,7 @@ class ActiveSessionEngine( loggedRpe = coordinator._currentSetRpe.value, isPr = false, completedAt = currentTimeMillis(), + setEndReason = coordinator.lastSetEndReason, ) completedSetRepository.saveCompletedSet(completedSet) Logger.d("Saved CompletedSet: set #$setIndex, $working reps @ ${savedWeightKg}kg${if (matchedPlannedSetId != null) " (linked to PlannedSet)" else ""}") @@ -3920,13 +3934,16 @@ class ActiveSessionEngine( * Phase A: Stop BLE, save session, emit haptics, show summary. * Phase B: Rest timer, navigation advancement (delegated back to DWSM via startRestTimer). */ - internal fun handleSetCompletion() { + internal fun handleSetCompletion(reason: SetEndReason = SetEndReason.TARGET_REPS_REACHED) { // 1.2: Atomic compareAndSet prevents duplicate set completion across dispatchers if (!coordinator.setCompletionInProgress.compareAndSet(expect = false, update = true)) { Logger.d("handleSetCompletion: already in progress - ignoring") return } + // Issue #673: Store reason for CompletedSet persistence + coordinator.lastSetEndReason = reason + // Issue #319: Log full context at entry so we can diagnose what the pipeline receives val repCount = coordinator._repCount.value val entryParams = coordinator._workoutParameters.value diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt index e1e8a0043..6df0b7a9d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/WorkoutCoordinator.kt @@ -16,6 +16,7 @@ import com.devil.phoenixproject.domain.model.RoutineFlowState import com.devil.phoenixproject.domain.model.RoutineLaunchOrigin import com.devil.phoenixproject.domain.model.RoutineGroup import com.devil.phoenixproject.domain.model.SessionBodyweightState +import com.devil.phoenixproject.domain.model.SetEndReason import com.devil.phoenixproject.domain.model.WeightAdjustmentRecommendation import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters @@ -412,6 +413,9 @@ class WorkoutCoordinator( // Guard to prevent duplicate auto-completion when rep target is reached internal val setCompletionInProgress = MutableStateFlow(false) + // Issue #673: Tracks why the current set ended, set by handleSetCompletion + internal var lastSetEndReason: SetEndReason = SetEndReason.TARGET_REPS_REACHED + // Issue #355: Guard to prevent duplicate proceedFromSummary() calls on iOS // When app foregrounds, both manager-level fallback AND UI-level countdown can fire internal val proceedFromSummaryInProgress = MutableStateFlow(false) @@ -431,6 +435,11 @@ class WorkoutCoordinator( @Volatile internal var stallArmedByDeload = false + // Issue #673: Track the reason for the auto-stop (STALL_FAILURE vs CABLE_RELEASED) + @Volatile + internal var autoStopReason: com.devil.phoenixproject.domain.model.SetEndReason = + com.devil.phoenixproject.domain.model.SetEndReason.STALL_FAILURE + // Issue #649: defer position/stall auto-stop until the verbal-cue + short // transition window elapses, or a completed working rep clears it. The // deadline (@Volatile Long) is the single source of truth — 0L means no @@ -458,6 +467,7 @@ class WorkoutCoordinator( stallStartTime = null isCurrentlyStalled = false stallArmedByDeload = false + autoStopReason = com.devil.phoenixproject.domain.model.SetEndReason.STALL_FAILURE deferAutoStopDeadlineMs = 0L _autoStopState.value = AutoStopUiState() } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt index 973756357..e3316f597 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BackupModels.kt @@ -315,6 +315,7 @@ data class CompletedSetBackup( val loggedRpe: Int? = null, val isPr: Boolean = false, val completedAt: Long, + val setEndReason: String = "TARGET_REPS_REACHED", ) /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt index 7441f8ba4..55af2ed98 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BleConstants.kt @@ -95,8 +95,8 @@ object BleConstants { // Force config block const val OFFSET_FORCE_MIN = 0x50 // 0.0f in activation packets const val OFFSET_FORCE_MAX = 0x54 // adjustedWeight + 10.0f (force ceiling) - const val OFFSET_TARGET_WEIGHT = 0x58 // adjustedWeight (actual operating weight) - const val OFFSET_PROGRESSION = 0x5C // progressionRegressionKg + const val OFFSET_TARGET_WEIGHT = 0x58 // official: softMax — adjustedWeight (actual operating weight) + const val OFFSET_PROGRESSION = 0x5C // official: increment — progressionRegressionKg (per-rep progression) } // Legacy aliases for backward compatibility diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt index 0e0496f3e..07405ed1a 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/BlePacketFactory.kt @@ -232,6 +232,17 @@ object BlePacketFactory { } } + // Issue #673: Signed protocol contract — softMax must not exceed 100.0f. + // The firmware's signed 8-bit protocol field limits the maximum to 100kg. + // Constants.MAX_WEIGHT_PER_CABLE_KG (110) is the UI/display maximum but + // must not be sent over BLE as softMax. + require(params.weightPerCableKg <= 100.0f) { + "weightPerCableKg=${params.weightPerCableKg} exceeds signed protocol softMax bound (100.0f)" + } + require(kotlin.math.abs(params.progressionRegressionKg) <= 10.0f) { + "progressionRegressionKg=${params.progressionRegressionKg} exceeds firmware increment bound (10.0f)" + } + if (effectiveVariant == ForceConfigVariant.OVERLAP) { // Legacy Phoenix behavior: overwrite the profile tail with softMax // and increment. Production uses NON_OVERLAP to match the official app. diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt index b77bb1691..a1a01420b 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/DataBackupManager.kt @@ -877,6 +877,7 @@ abstract class BaseDataBackupManager( logged_rpe = completedSet.loggedRpe?.toLong(), is_pr = if (completedSet.isPr) 1L else 0L, completed_at = completedSet.completedAt, + set_end_reason = completedSet.setEndReason, ) completedSetsImported++ } @@ -1797,6 +1798,7 @@ abstract class BaseDataBackupManager( logged_rpe = completedSet.loggedRpe?.toLong(), is_pr = if (completedSet.isPr) 1L else 0L, completed_at = completedSet.completedAt, + set_end_reason = completedSet.setEndReason, ) completedSetsImported++ } @@ -2986,6 +2988,7 @@ abstract class BaseDataBackupManager( loggedRpe = cs.logged_rpe?.toInt(), isPr = cs.is_pr != 0L, completedAt = cs.completed_at, + setEndReason = cs.set_end_reason, ) private fun mapProgressionEventToBackup(pe: ProgressionEvent): ProgressionEventBackup = ProgressionEventBackup( diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt index 0cab8d941..39ad360c6 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidator.kt @@ -35,6 +35,15 @@ object WorkoutCommandValidator { validateFiniteWeight(params.weightPerCableKg).onFailure { return Result.failure(it) } validateFiniteWeight(params.progressionRegressionKg, field = "progressionRegressionKg") .onFailure { return Result.failure(it) } + // Issue #673: Validate firmware increment bound early (±10kg) + // so invalid imported/synced values are rejected before reaching + // BlePacketFactory.createProgramParams() which enforces the same + // bound via require(). + if (kotlin.math.abs(params.progressionRegressionKg) > 10.0f) { + return failure( + "progressionRegressionKg must be within ±10.0f (firmware increment bound), got ${params.progressionRegressionKg}", + ) + } if (params.isJustLift && params.weightPerCableKg < Constants.JUST_LIFT_MIN_VALID_WEIGHT_KG) { return failure( @@ -99,13 +108,18 @@ object WorkoutCommandValidator { private fun isFinite(value: Float): Boolean = !value.isNaN() && !value.isInfinite() + // Signed protocol softMax bound: the firmware's BLE activation packet limits + // the maximum weight to 100.0f. Constants.MAX_WEIGHT_PER_CABLE_KG (110) is the + // UI/display maximum but must not be sent as softMax over BLE. + private const val SIGNED_PROTOCOL_SOFT_MAX_KG = 100.0f + private fun validateWeightRange(weightPerCableKg: Float, allowZero: Boolean): Result { if (!allowZero && weightPerCableKg <= Constants.MIN_WEIGHT_KG) { return failure("weightPerCableKg must be greater than ${Constants.MIN_WEIGHT_KG}kg, got $weightPerCableKg") } - if (weightPerCableKg < Constants.MIN_WEIGHT_KG || weightPerCableKg > Constants.MAX_WEIGHT_PER_CABLE_KG) { + if (weightPerCableKg < Constants.MIN_WEIGHT_KG || weightPerCableKg > SIGNED_PROTOCOL_SOFT_MAX_KG) { return failure( - "weightPerCableKg must be ${Constants.MIN_WEIGHT_KG}..${Constants.MAX_WEIGHT_PER_CABLE_KG}kg, got $weightPerCableKg", + "weightPerCableKg must be ${Constants.MIN_WEIGHT_KG}..${SIGNED_PROTOCOL_SOFT_MAX_KG}kg, got $weightPerCableKg", ) } return Result.success(Unit) diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq index 9c4d689e4..da0764565 100644 --- a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/VitruvianDatabase.sq @@ -1836,6 +1836,7 @@ CREATE TABLE CompletedSet ( logged_rpe INTEGER, is_pr INTEGER NOT NULL DEFAULT 0, completed_at INTEGER NOT NULL, + set_end_reason TEXT NOT NULL DEFAULT 'TARGET_REPS_REACHED', FOREIGN KEY (session_id) REFERENCES WorkoutSession(id) ON DELETE CASCADE, FOREIGN KEY (planned_set_id) REFERENCES PlannedSet(id) ON DELETE SET NULL ); @@ -2023,8 +2024,8 @@ ORDER BY cs.completed_at DESC LIMIT ?; insertCompletedSet: -INSERT INTO CompletedSet (id, session_id, planned_set_id, set_number, set_type, actual_reps, actual_weight_kg, logged_rpe, is_pr, completed_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT INTO CompletedSet (id, session_id, planned_set_id, set_number, set_type, actual_reps, actual_weight_kg, logged_rpe, is_pr, completed_at, set_end_reason) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); updateCompletedSetRpe: UPDATE CompletedSet SET logged_rpe = ? WHERE id = ?; @@ -2161,8 +2162,8 @@ INSERT OR IGNORE INTO PlannedSet (id, routine_exercise_id, set_number, set_type, VALUES (?, ?, ?, ?, ?, ?, ?, ?); insertCompletedSetIgnore: -INSERT OR IGNORE INTO CompletedSet (id, session_id, planned_set_id, set_number, set_type, actual_reps, actual_weight_kg, logged_rpe, is_pr, completed_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT OR IGNORE INTO CompletedSet (id, session_id, planned_set_id, set_number, set_type, actual_reps, actual_weight_kg, logged_rpe, is_pr, completed_at, set_end_reason) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); insertProgressionEventIgnore: INSERT OR IGNORE INTO ProgressionEvent (id, exercise_id, suggested_weight_kg, previous_weight_kg, reason, user_response, actual_weight_kg, timestamp, profile_id) diff --git a/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm new file mode 100644 index 000000000..f8418ed86 --- /dev/null +++ b/shared/src/commonMain/sqldelight/com/devil/phoenixproject/database/migrations/43.sqm @@ -0,0 +1,3 @@ +-- Migration 43: Add set_end_reason to CompletedSet (Issue #673 PR 1) +-- Records why a set ended for workout history analytics. +ALTER TABLE CompletedSet ADD COLUMN set_end_reason TEXT NOT NULL DEFAULT 'TARGET_REPS_REACHED'; diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt index b92bbcc15..938cfaf74 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/util/WorkoutCommandValidatorTest.kt @@ -206,6 +206,91 @@ class WorkoutCommandValidatorTest { ) } + @Test + fun `progressionRegressionKg within plus minus 10 is accepted`() { + assertTrue( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 40f, + progressionRegressionKg = 10.0f, + ), + ).isSuccess, + ) + assertTrue( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 40f, + progressionRegressionKg = -10.0f, + ), + ).isSuccess, + ) + assertTrue( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 40f, + progressionRegressionKg = 0.0f, + ), + ).isSuccess, + ) + } + + @Test + fun `progressionRegressionKg outside plus minus 10 is rejected early`() { + assertFailureContains( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 40f, + progressionRegressionKg = 11.0f, + ), + ), + "progressionRegressionKg", + ) + assertFailureContains( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 40f, + progressionRegressionKg = -15.0f, + ), + ), + "progressionRegressionKg", + ) + } + + @Test + fun `program params reject weightPerCableKg above signed protocol softMax 100`() { + // 101kg exceeds the signed protocol softMax bound (100.0f) + assertFailureContains( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 101f, + ), + ), + "weightPerCableKg", + ) + // 100kg is exactly at the bound — accepted by the validator (BlePacketFactory enforces the hard limit) + assertTrue( + WorkoutCommandValidator.validateProgramParams( + WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 8, + weightPerCableKg = 100f, + ), + ).isSuccess, + ) + } + private fun validColors(): List = listOf( RGBColor(255, 0, 0), RGBColor(0, 255, 0),