Skip to content
Open
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
4 changes: 2 additions & 2 deletions shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,10 +638,73 @@
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"))

Check failure on line 671 in shared/src/androidHostTest/kotlin/com/devil/phoenixproject/data/local/SchemaParityTest.kt

View workflow job for this annotation

GitHub Actions / Shared Module Test Results

SchemaParityTest.migration 42 to 43 adds set_end_reason column and preserves existing rows with default

java.lang.AssertionError: expected:<true> but was:<false>
Raw output
java.lang.AssertionError: expected:<true> but was:<false>
	at org.junit.Assert.fail(Assert.java:89)
	at org.junit.Assert.failNotEquals(Assert.java:835)
	at org.junit.Assert.assertEquals(Assert.java:120)
	at kotlin.test.junit.JUnitAsserter.assertEquals(JUnitSupport.kt:32)
	at kotlin.test.AssertionsKt__AssertionsKt.assertEquals(Assertions.kt:63)
	at kotlin.test.AssertionsKt.assertEquals(Unknown Source)
	at kotlin.test.AssertionsKt__AssertionsKt.assertEquals$default(Assertions.kt:62)
	at kotlin.test.AssertionsKt.assertEquals$default(Unknown Source)
	at com.devil.phoenixproject.data.local.SchemaParityTest.migration 42 to 43 adds set_end_reason column and preserves existing rows with default(SchemaParityTest.kt:671)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
	at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
	at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
	at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
	at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
	at jdk.internal.reflect.GeneratedMethodAccessor7.invoke(Unknown Source)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:569)
	at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
	at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
	at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
	at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
	at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)

// 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}$")

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -100,6 +132,7 @@ class SqlDelightCompletedSetRepositoryTest {
loggedRpe = null,
isPr = false,
completedAt = 1000L + setNumber,
setEndReason = setEndReason,
)

private fun insertRoutine(id: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@ internal val manifestTables: List<SchemaTableOperation> = 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 = """
Expand All @@ -1156,6 +1156,7 @@ internal val manifestTables: List<SchemaTableOperation> = 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',
Comment thread
9thLevelSoftware marked this conversation as resolved.
FOREIGN KEY (session_id) REFERENCES WorkoutSession(id) ON DELETE CASCADE,
FOREIGN KEY (planned_set_id) REFERENCES PlannedSet(id) ON DELETE SET NULL
)
Expand Down Expand Up @@ -1437,6 +1438,10 @@ internal val manifestColumns: List<SchemaHealOperation> = 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'"),
)

// ============================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +55,7 @@ class SqlDelightCompletedSetRepository(db: VitruvianDatabase) : CompletedSetRepo
loggedRpe: Long?,
isPr: Long,
completedAt: Long,
setEndReason: String,
): CompletedSet = CompletedSet(
id = id,
sessionId = sessionId,
Expand All @@ -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 ====================
Expand Down Expand Up @@ -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,
)
}
}
Expand Down Expand Up @@ -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,

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 Preserve the end reason when tagging Just Lift sets

When a Just Lift session finishes without an exercise selected, no CompletedSet is saved by the completion path; tagging it later creates the row here from a CompletedSet whose default reason is TARGET_REPS_REACHED. Thus sessions actually ended by stall, cable release, or manual stop are misclassified as soon as they are tagged. Pass the captured completion reason through the Just Lift tagging/ensure path rather than serializing the constructor default.

Useful? React with 👍 / 👎.

)

completedSet
Expand All @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand All @@ -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,
Expand All @@ -423,6 +444,7 @@ data class CompletedSet(
loggedRpe = loggedRpe,
isPr = isPr,
completedAt = currentTimeMillis(),
setEndReason = setEndReason,
)
}
}
Expand Down
Loading
Loading