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
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,37 @@ class SqlDelightPersonalRecordRepositoryTest {
assertEquals(70f, restoredPr.weightPerCableKg)
}

@Test
fun `a lower valid PR replaces a deleted outlier with its stable sync uuid`() = runTest {
repository.updatePRsIfBetter(
exerciseId = "bench",
weightPRWeightPerCableKg = 100f,
volumePRWeightPerCableKg = 100f,
reps = 5,
workoutMode = "Old School",
timestamp = 2_000L,
profileId = "default",
).getOrThrow()
val deletedPr = assertNotNull(repository.getWeightPR("bench", "Old School", "default"))
val stableUuid = assertNotNull(deletedPr.uuid)
repository.deletePR(deletedPr.id, "default")

val result = repository.updatePRsIfBetter(
exerciseId = "bench",
weightPRWeightPerCableKg = 90f,
volumePRWeightPerCableKg = 90f,
reps = 5,
workoutMode = "Old School",
timestamp = 3_000L,
profileId = "default",
).getOrThrow()

val replacementPr = assertNotNull(repository.getWeightPR("bench", "Old School", "default"))
assertTrue(result.contains(PRType.MAX_WEIGHT))
assertEquals(stableUuid, replacementPr.uuid)
assertEquals(90f, replacementPr.weightPerCableKg)
}

private fun insertExercise(id: String, name: String) {
database.vitruvianDatabaseQueries.insertExercise(
id = id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,30 +172,32 @@ class SqlDelightSyncRepositoryTest {
@Test
fun `mergePersonalRecords keeps a newer tombstone and rejects stale active replay`() = runTest {
val prId = "12345678-1234-4abc-8def-1234567890cc"
fun syncDto(updatedAt: Long, deletedAt: Long?) = PersonalRecordSyncDto(
fun syncDto(updatedAt: Long, deletedAt: Long?, weight: Float = 85f) = PersonalRecordSyncDto(
clientId = prId,
serverId = prId,
exerciseId = "deadlift",
exerciseName = "Deadlift",
weight = 85f,
weight = weight,
reps = 5,
oneRepMax = 99.17f,
oneRepMax = weight * 1.1667f,
achievedAt = 1_700_000_000_000L,
workoutMode = "Old School",
prType = PRType.MAX_WEIGHT.name,
volume = 425f,
volume = weight * 5,
createdAt = 1_700_000_000_000L,
updatedAt = updatedAt,
deletedAt = deletedAt,
)

repository.mergePersonalRecords(listOf(syncDto(100L, null)), "active-profile")
repository.mergePersonalRecords(listOf(syncDto(200L, 200L)), "active-profile")
repository.mergePersonalRecords(listOf(syncDto(200L, 200L, weight = 0f)), "active-profile")
repository.mergePersonalRecords(listOf(syncDto(150L, null)), "active-profile")

val row = database.vitruvianDatabaseQueries.selectAllRecordsSync().executeAsOne()
assertEquals(200L, row.updatedAt)
assertEquals(200L, row.deletedAt)
assertEquals(85.0, row.weight)
assertEquals(425.0, row.volume)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,9 @@ class SqlDelightPersonalRecordRepository(private val db: VitruvianDatabase) : Pe
// Issue #319: Log the profile context being used
Logger.d { "PR_SAVE: Checking for exercise=$exerciseId, mode=$canonicalWorkoutMode, phase=$phaseName, profile=$effectiveProfileId" }

// Check weight PR for this phase
val currentWeightPR = queries.selectPRIncludingDeleted(
// Keep a tombstone's UUID for sync identity, but do not let a deleted
// outlier remain the active comparison baseline for future PRs.
val storedWeightPR = queries.selectPRIncludingDeleted(
exerciseId,
canonicalWorkoutMode,
PRType.MAX_WEIGHT.name,
Expand All @@ -306,6 +307,7 @@ class SqlDelightPersonalRecordRepository(private val db: VitruvianDatabase) : Pe
mapper = ::mapToPR,
)
.executeAsOneOrNull()
val currentWeightPR = storedWeightPR?.takeIf { it.deletedAt == null }

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 Recompute stored 1RM when reviving a deleted PR

When the deleted row was an outlier that previously raised Exercise.one_rep_max_kg, this new filtering makes a later lower lift count as a new active PR, but the same method still only updates the exercise 1RM when the new estimate is greater than the stored value. In that scenario the active PR is replaced with the lower valid lift while ESTIMATED_1RM / percentage-based weight paths keep using the deleted outlier’s stale 1RM, so deleting an erroneous max still leaves generated weights too high.

Useful? React with 👍 / 👎.


// Issue #319: Detect profile mismatch if a PR exists with a different profile_id
if (currentWeightPR != null && currentWeightPR.profileId != effectiveProfileId) {
Expand All @@ -321,8 +323,8 @@ class SqlDelightPersonalRecordRepository(private val db: VitruvianDatabase) : Pe
"new=${weightPRWeightPerCableKg}kg vs current=${currentWeightPR?.weightPerCableKg ?: "NONE"} → ${if (isNewWeightPR) "NEW PR" else "no change"}"
}

// Check volume PR for this phase
val currentVolumePR = queries.selectPRIncludingDeleted(
// Keep the deleted volume snapshot only for UUID reuse, not comparison.
val storedVolumePR = queries.selectPRIncludingDeleted(
exerciseId,
canonicalWorkoutMode,
PRType.MAX_VOLUME.name,
Expand All @@ -331,6 +333,7 @@ class SqlDelightPersonalRecordRepository(private val db: VitruvianDatabase) : Pe
mapper = ::mapToPR,
)
.executeAsOneOrNull()
val currentVolumePR = storedVolumePR?.takeIf { it.deletedAt == null }

// Issue #319: Detect profile mismatch for volume PR too
if (currentVolumePR != null && currentVolumePR.profileId != effectiveProfileId) {
Expand Down Expand Up @@ -364,7 +367,9 @@ class SqlDelightPersonalRecordRepository(private val db: VitruvianDatabase) : Pe
phase = phaseName,
profile_id = effectiveProfileId,
cable_count = cableCount?.toLong(),
uuid = currentWeightPR?.uuid ?: generateUUID(),
// A deleted snapshot is no longer the active comparison
// baseline, but its UUID is retained until sync converges.
uuid = storedWeightPR?.uuid ?: generateUUID(),
)
brokenPRs.add(PRType.MAX_WEIGHT)
}
Expand All @@ -384,7 +389,7 @@ class SqlDelightPersonalRecordRepository(private val db: VitruvianDatabase) : Pe
phase = phaseName,
profile_id = effectiveProfileId,
cable_count = cableCount?.toLong(),
uuid = currentVolumePR?.uuid ?: generateUUID(),
uuid = storedVolumePR?.uuid ?: generateUUID(),
)
brokenPRs.add(PRType.MAX_VOLUME)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,9 +545,8 @@ fun AnalyticsScreen(
onDeletePersonalRecord = { pr ->
personalRecordRepository.deletePR(pr.id, pr.profileId)
},
onDeletePersonalRecordError = { error ->
exportMessage = error.message?.takeIf { it.isNotBlank() }
?: deletePersonalRecordFailed
onDeletePersonalRecordError = { _ ->
exportMessage = deletePersonalRecordFailed
},
modifier = Modifier.fillMaxSize(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1147,13 +1147,16 @@ applyPulledPersonalRecordTombstoneState:
UPDATE PersonalRecord
SET deletedAt = :deletedAt,
updatedAt = :updatedAt,
exerciseName = :exerciseName,
weight = :weight,
reps = :reps,
oneRepMax = :oneRepMax,
achievedAt = :achievedAt,
volume = :volume,
cable_count = :cableCount
-- Portal tombstone DTOs intentionally contain only identity and deletion
-- state. Preserve the last active metrics for a tombstone, but replace the
-- full snapshot when a newer active record legitimately restores it.
exerciseName = CASE WHEN :deletedAt IS NULL THEN :exerciseName ELSE exerciseName END,
weight = CASE WHEN :deletedAt IS NULL THEN :weight ELSE weight END,
reps = CASE WHEN :deletedAt IS NULL THEN :reps ELSE reps END,
oneRepMax = CASE WHEN :deletedAt IS NULL THEN :oneRepMax ELSE oneRepMax END,
achievedAt = CASE WHEN :deletedAt IS NULL THEN :achievedAt ELSE achievedAt END,
volume = CASE WHEN :deletedAt IS NULL THEN :volume ELSE volume END,
cable_count = CASE WHEN :deletedAt IS NULL THEN :cableCount ELSE cable_count END
WHERE uuid = :uuid
AND profile_id = :profileId
AND (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.devil.phoenixproject.presentation.screen
import com.devil.phoenixproject.testutil.readProjectFile
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFalse
import kotlin.test.assertNotNull

class AnalyticsPersonalRecordDeleteReviewTest {
Expand All @@ -24,6 +25,7 @@ class AnalyticsPersonalRecordDeleteReviewTest {
pendingDelete = null
}""",
)
assertContains(analytics, "exportMessage = error.message?.takeIf { it.isNotBlank() }")
assertContains(analytics, "exportMessage = deletePersonalRecordFailed")
assertFalse(analytics.contains("exportMessage = error.message?.takeIf { it.isNotBlank() }"))
}
}
Loading