Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ class SchemaParityTest {
// ==================== 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 @@ -965,6 +965,8 @@ class MigrationManagerTest {
prTypeForScaling = "MAX_WEIGHT",
setWeightsPercentOfPR = null,
stallDetectionEnabled = 1,
dropSetEnabled = 0,
dropSetMinWeightKg = 0.0,
stopAtTop = 0,
repCountTiming = "TOP",
setEchoLevels = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ class SqlDelightCompletedSetRepositoryTest {
prTypeForScaling = "MAX_WEIGHT",
setWeightsPercentOfPR = null,
stallDetectionEnabled = 1L,
dropSetEnabled = 0L,
dropSetMinWeightKg = 0.0,
stopAtTop = 0L,
repCountTiming = "TOP",
setEchoLevels = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,8 @@ class SqlDelightSyncRepositoryTest {
prTypeForScaling = "MAX_WEIGHT",
setWeightsPercentOfPR = null,
stallDetectionEnabled = 1,
dropSetEnabled = 0,
dropSetMinWeightKg = 0.0,
stopAtTop = 0,
repCountTiming = "TOP",
setEchoLevels = "",
Expand Down Expand Up @@ -778,7 +780,7 @@ class SqlDelightSyncRepositoryTest {
}

@Test
fun `mergePortalRoutines preserves local scalingBasis across portal pull`() = runTest {
fun `mergePortalRoutines preserves local scalingBasis and drop set fields across portal pull`() = runTest {
database.vitruvianDatabaseQueries.insertRoutine(
id = "routine-scaling-basis",
name = "Scaling Basis",
Expand All @@ -805,7 +807,7 @@ class SqlDelightSyncRepositoryTest {
mode = "OldSchool",
eccentricLoad = 100,
echoLevel = 1,
progressionKg = 0.0,
progressionKg = -8.0,
restSeconds = 90,
duration = null,
setRestSeconds = "[]",
Expand All @@ -818,6 +820,8 @@ class SqlDelightSyncRepositoryTest {
prTypeForScaling = "MAX_WEIGHT",
setWeightsPercentOfPR = null,
stallDetectionEnabled = 1,
dropSetEnabled = 1,
dropSetMinWeightKg = 17.5,
stopAtTop = 0,
repCountTiming = "TOP",
setEchoLevels = "",
Expand Down Expand Up @@ -857,6 +861,9 @@ class SqlDelightSyncRepositoryTest {
.executeAsList()
.single()
assertEquals("ESTIMATED_1RM", exercise.scalingBasis)
assertEquals(1L, exercise.dropSetEnabled)
assertEquals(17.5, exercise.dropSetMinWeightKg)
assertEquals(-8.0, exercise.progressionKg)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ class SqlDelightTrainingCycleRepositoryTest {
prTypeForScaling = "MAX_WEIGHT",
setWeightsPercentOfPR = null,
stallDetectionEnabled = 1L,
dropSetEnabled = 0L,
dropSetMinWeightKg = 0.0,
stopAtTop = 0L,
repCountTiming = "TOP",
setEchoLevels = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ class ExerciseConfigViewModelTest {
assertEquals(SetMode.DURATION, viewModel.setMode.value)
}

@Test
fun `initialize clears stale drop set mode outside Old School`() = runTest {
val viewModel = ExerciseConfigViewModel()
val exercise = RoutineExercise(
id = "rex-drop-set-pump",
exercise = Exercise(
id = "cable-row-1",
name = "Cable Row",
muscleGroup = "Back",
muscleGroups = "Back",
equipment = "CABLE",
),
orderIndex = 0,
setReps = listOf(10),
weightPerCableKg = 30f,
progressionKg = -2f,
programMode = ProgramMode.Pump,
dropSetEnabled = true,
dropSetMinWeightKg = 15f,
)

viewModel.initialize(
exercise = exercise,
unit = WeightUnit.KG,
toDisplay = { value, _ -> value },
toKg = { value, _ -> value },
)

assertFalse(viewModel.dropSetEnabled.value)
var saved: RoutineExercise? = null
viewModel.onSave { saved = it }
assertFalse(saved?.dropSetEnabled ?: true)
}

@Test
fun `bodyweight exercise hides cable-only configuration toggles`() {
val bodyweightSets = listOf(SetConfiguration(setNumber = 1, reps = 10))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,8 @@ internal val manifestTables: List<SchemaTableOperation> = listOf(
setWeightsPercentOfPR TEXT,
scalingBasis TEXT,
stallDetectionEnabled INTEGER NOT NULL DEFAULT 1,
dropSetEnabled INTEGER NOT NULL DEFAULT 0,
dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0,
stopAtTop INTEGER NOT NULL DEFAULT 0,
repCountTiming TEXT NOT NULL DEFAULT 'TOP',
setEchoLevels TEXT NOT NULL DEFAULT '',
Expand Down Expand Up @@ -1374,6 +1376,9 @@ internal val manifestColumns: List<SchemaHealOperation> = listOf(
SchemaHealOperation("RoutineExercise", "stallDetectionEnabled", "ALTER TABLE RoutineExercise ADD COLUMN stallDetectionEnabled INTEGER NOT NULL DEFAULT 1"),
SchemaHealOperation("RoutineExercise", "stopAtTop", "ALTER TABLE RoutineExercise ADD COLUMN stopAtTop INTEGER NOT NULL DEFAULT 0"),
SchemaHealOperation("RoutineExercise", "repCountTiming", "ALTER TABLE RoutineExercise ADD COLUMN repCountTiming TEXT NOT NULL DEFAULT 'TOP'"),
// Migration 43: drop-set mode for Old School (issue #673)
SchemaHealOperation("RoutineExercise", "dropSetEnabled", "ALTER TABLE RoutineExercise ADD COLUMN dropSetEnabled INTEGER NOT NULL DEFAULT 0"),
SchemaHealOperation("RoutineExercise", "dropSetMinWeightKg", "ALTER TABLE RoutineExercise ADD COLUMN dropSetMinWeightKg REAL NOT NULL DEFAULT 0.0"),

// ── UserProfile (4 columns) ─────────────────────────────────────────

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ data class SingleExerciseDefaults(
val isAMRAP: Boolean,
val perSetRestTime: Boolean,
val defaultRackItemIds: List<String> = emptyList(),
val dropSetEnabled: Boolean = false,
val dropSetMinWeightKg: Float = 0f,
) {
fun getEccentricLoad(): com.devil.phoenixproject.domain.model.EccentricLoad {
// Handle legacy 125% -> fall back to 120%
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ internal fun SingleExerciseDefaults.toDocument() = SingleExerciseDefaultsDocumen
isAMRAP = isAMRAP,
perSetRestTime = perSetRestTime,
defaultRackItemIds = defaultRackItemIds,
dropSetEnabled = dropSetEnabled,
dropSetMinWeightKg = dropSetMinWeightKg,
)

internal fun SingleExerciseDefaultsDocument.toLegacySingleExerciseDefaults() = SingleExerciseDefaults(
Expand All @@ -33,6 +35,8 @@ internal fun SingleExerciseDefaultsDocument.toLegacySingleExerciseDefaults() = S
isAMRAP = isAMRAP,
perSetRestTime = perSetRestTime,
defaultRackItemIds = defaultRackItemIds,
dropSetEnabled = dropSetEnabled,
dropSetMinWeightKg = dropSetMinWeightKg,
)

internal fun com.devil.phoenixproject.data.preferences.JustLiftDefaults.toDocument() =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -586,13 +586,23 @@ class SqlDelightSyncRepository(
.associate { it.id to it.rackBehaviorOverrides }
val localScalingBasisByExerciseId = localExerciseRows
.associate { it.id to it.scalingBasis }
val localDropSetEnabledByExerciseId = localExerciseRows
.associate { it.id to it.dropSetEnabled }
val localDropSetMinWeightByExerciseId = localExerciseRows
.associate { it.id to it.dropSetMinWeightKg }
val localDropSetProgressionByExerciseId = localExerciseRows
.filter { it.dropSetEnabled != 0L }
.associate { it.id to it.progressionKg }

mergePortalExercisesForRoutine(
routineId = portalRoutine.id,
portalExercises = portalRoutine.exercises,
localRackDefaultsByExerciseId = localRackDefaultsByExerciseId,
localRackOverridesByExerciseId = localRackOverridesByExerciseId,
localScalingBasisByExerciseId = localScalingBasisByExerciseId,
localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId,
localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId,
localDropSetProgressionByExerciseId = localDropSetProgressionByExerciseId,
)
} else {
Logger.w("SyncRepository") {
Expand Down Expand Up @@ -1043,6 +1053,8 @@ class SqlDelightSyncRepository(
isAMRAP = exRow.isAMRAP == 1L,
perSetRestTime = exRow.perSetRestTime == 1L,
stallDetectionEnabled = exRow.stallDetectionEnabled == 1L,
dropSetEnabled = exRow.dropSetEnabled == 1L,
dropSetMinWeightKg = exRow.dropSetMinWeightKg.toFloat(),
repCountTiming = try {
RepCountTiming.valueOf(exRow.repCountTiming)
} catch (
Expand Down Expand Up @@ -1660,13 +1672,23 @@ class SqlDelightSyncRepository(
.associate { it.id to it.rackBehaviorOverrides }
val localScalingBasisByExerciseId = localExerciseRows2
.associate { it.id to it.scalingBasis }
val localDropSetEnabledByExerciseId2 = localExerciseRows2
.associate { it.id to it.dropSetEnabled }
val localDropSetMinWeightByExerciseId2 = localExerciseRows2
.associate { it.id to it.dropSetMinWeightKg }
val localDropSetProgressionByExerciseId2 = localExerciseRows2
.filter { it.dropSetEnabled != 0L }
.associate { it.id to it.progressionKg }

mergePortalExercisesForRoutine(
routineId = portalRoutine.id,
portalExercises = portalRoutine.exercises,
localRackDefaultsByExerciseId = localRackDefaultsByExerciseId,
localRackOverridesByExerciseId = localRackOverridesByExerciseId,
localScalingBasisByExerciseId = localScalingBasisByExerciseId,
localDropSetEnabledByExerciseId = localDropSetEnabledByExerciseId2,
localDropSetMinWeightByExerciseId = localDropSetMinWeightByExerciseId2,
localDropSetProgressionByExerciseId = localDropSetProgressionByExerciseId2,
)
}
}
Expand Down Expand Up @@ -2161,6 +2183,9 @@ class SqlDelightSyncRepository(
localRackDefaultsByExerciseId: Map<String, String?>,
localRackOverridesByExerciseId: Map<String, String?>,
localScalingBasisByExerciseId: Map<String, String?>,
localDropSetEnabledByExerciseId: Map<String, Long> = emptyMap(),
localDropSetMinWeightByExerciseId: Map<String, Double> = emptyMap(),
localDropSetProgressionByExerciseId: Map<String, Double> = emptyMap(),
) {
queries.deleteRoutineExercises(routineId)
queries.deleteSupersetsByRoutine(routineId)
Expand Down Expand Up @@ -2269,7 +2294,7 @@ class SqlDelightSyncRepository(
mode = mobileMode,
eccentricLoad = PortalPullAdapter.parseEccentricLoad(exercise.eccentricLoad),
echoLevel = PortalPullAdapter.parseEchoLevel(exercise.echoLevel),
progressionKg = 0.0,
progressionKg = localDropSetProgressionByExerciseId[exercise.id] ?: 0.0,
restSeconds = exercise.restSeconds.toLong(),
duration = null,
setRestSeconds = setRestSeconds,
Expand All @@ -2282,6 +2307,8 @@ class SqlDelightSyncRepository(
prTypeForScaling = "MAX_WEIGHT",
setWeightsPercentOfPR = null,
stallDetectionEnabled = if (exercise.stallDetection) 1L else 0L,
dropSetEnabled = localDropSetEnabledByExerciseId[exercise.id] ?: 0L,
Comment thread
9thLevelSoftware marked this conversation as resolved.
dropSetMinWeightKg = localDropSetMinWeightByExerciseId[exercise.id] ?: 0.0,
stopAtTop = if (exercise.stopAtPosition == "TOP") 1L else 0L,
repCountTiming = exercise.repCountTiming ?: "TOP",
setEchoLevels = setEchoLevels,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ class SqlDelightWorkoutRepository(private val db: VitruvianDatabase, private val
perSetRestTime = row.perSetRestTime == 1L,
// Per-exercise behavior overrides
stallDetectionEnabled = row.stallDetectionEnabled == 1L,
dropSetEnabled = row.dropSetEnabled == 1L,
dropSetMinWeightKg = row.dropSetMinWeightKg.toFloat(),
repCountTiming = try {
com.devil.phoenixproject.domain.model.RepCountTiming.valueOf(row.repCountTiming)
} catch (
Expand Down Expand Up @@ -751,6 +753,8 @@ class SqlDelightWorkoutRepository(private val db: VitruvianDatabase, private val
},
// Per-exercise behavior overrides
stallDetectionEnabled = if (exercise.stallDetectionEnabled) 1L else 0L,
dropSetEnabled = if (exercise.dropSetEnabled) 1L else 0L,
dropSetMinWeightKg = exercise.dropSetMinWeightKg.toDouble(),
stopAtTop = if (exercise.stopAtTop) 1L else 0L,
repCountTiming = exercise.repCountTiming.name,
// Per-set echo levels (stored as JSON array of nullable ordinals)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ data class WorkoutParameters(
val externalAddedLoadKg: Float = 0f,
val counterweightKg: Float = 0f,
val progressionRegressionKg: Float = 0f, // Positive = progression, negative = regression
val dropSetEnabled: Boolean = false,
val dropSetMinWeightKg: Float = 0f,
val isJustLift: Boolean = false,
val useAutoStart: Boolean = false, // true for Just Lift, false for others
val stopAtTop: Boolean = false, // false = stop at bottom (extended), true = stop at top (contracted)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ data class SingleExerciseDefaultsDocument(
val isAMRAP: Boolean,
val perSetRestTime: Boolean,
val defaultRackItemIds: List<String> = emptyList(),
val dropSetEnabled: Boolean = false,
val dropSetMinWeightKg: Float = 0f,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ data class RoutineExercise(
val eccentricLoad: EccentricLoad = EccentricLoad.LOAD_100,
val echoLevel: EchoLevel = EchoLevel.HARDER,
val progressionKg: Float = 0f,
val dropSetEnabled: Boolean = false,
val dropSetMinWeightKg: Float = 0f,
val setRestSeconds: List<Int> = emptyList(), // per-set rest times
// Per-set echo level overrides; null entries fall back to exercise-level echoLevel
val setEchoLevels: List<EchoLevel?> = emptyList(),
Expand Down
Loading
Loading