fix(android): recover continuous session finalization
This commit is contained in:
parent
8646422bef
commit
a652644e24
10 changed files with 648 additions and 53 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
## Unreleased — 0.1.6
|
||||
|
||||
- Fixed Android finalization of Program-derived sessions containing a completed
|
||||
target-only continuous occurrence. The repository now materializes the
|
||||
performed duration from the persisted exercise timeline, including preserved
|
||||
drafts closed by an older build, while keeping planned set counts independent
|
||||
from valid actual sets, repetitions and loads. A strictly identity-bound
|
||||
private recovery build may supply the factual session end; immutable late
|
||||
heart-rate samples remain retained inside their capture bounds.
|
||||
- Hardened desktop Sleep generation consumption when an identical durable
|
||||
revision has no local publication row. Content comparison now reads the
|
||||
durable revision directly, acknowledges an exact match, and reports a real
|
||||
revision-identity conflict instead of an empty rejection diagnostic when the
|
||||
same ID carries different content.
|
||||
|
||||
Development opened after the stable v0.1.5 release. The Web refinement tranche
|
||||
adds persistent French/English settings, denser shared surfaces, ISO-week
|
||||
Dashboard activity, factual exercise-count overview charts, measured-timeline
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import java.time.OffsetDateTime
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
|
|
@ -45,6 +46,37 @@ val trainlogVersionCode =
|
|||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||
} ?: 7
|
||||
|
||||
/*
|
||||
* WHY: a user may need to finish one preserved draft after a completion bug
|
||||
* has been repaired, while its factual end time predates the repair build.
|
||||
* CONTRACT: both values are private-build inputs, must be supplied together,
|
||||
* and are embedded only after strict identity/timestamp validation.
|
||||
* INVARIANT: ordinary builds use empty values and every non-matching future
|
||||
* session continues to finalize at the live clock time.
|
||||
*/
|
||||
val recoverySessionId =
|
||||
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
|
||||
val recoveryEndedAt =
|
||||
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
|
||||
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
|
||||
throw GradleException(
|
||||
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
|
||||
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
|
||||
)
|
||||
}
|
||||
if (recoverySessionId.isNotEmpty()) {
|
||||
val sessionPattern =
|
||||
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
if (!sessionPattern.matches(recoverySessionId)) {
|
||||
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
|
||||
}
|
||||
try {
|
||||
OffsetDateTime.parse(recoveryEndedAt)
|
||||
} catch (_: Exception) {
|
||||
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
compileSdk = 37
|
||||
|
|
@ -56,6 +88,8 @@ android {
|
|||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.6"
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
@ -86,6 +120,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
|
|
|||
|
|
@ -1858,6 +1858,55 @@ class TrainlogRepository(
|
|||
"SELECT run_id FROM cardio_guidance_runs WHERE session_id=?)",
|
||||
arrayOf(endedAt, entryId, row.first),
|
||||
)
|
||||
|
||||
val continuous =
|
||||
db.rawQuery(
|
||||
"SELECT de.id,de.data_fields,ca.draft_exercise_row_id " +
|
||||
"FROM draft_session_exercises de " +
|
||||
"LEFT JOIN draft_continuous_activity ca " +
|
||||
"ON ca.draft_exercise_row_id=de.id " +
|
||||
"WHERE de.draft_id=1 AND de.entry_id=? " +
|
||||
"AND de.recording_mode='continuous'",
|
||||
arrayOf(entryId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else Triple(
|
||||
cursor.getLong(0),
|
||||
cursor.getInt(1),
|
||||
!cursor.isNull(2),
|
||||
)
|
||||
}
|
||||
if (continuous != null && !continuous.third) {
|
||||
check(continuous.second == 0) {
|
||||
"Continuous speed or distance must be recorded before the exercise is finished."
|
||||
}
|
||||
val measuredSeconds =
|
||||
Duration.between(
|
||||
OffsetDateTime.parse(row.third),
|
||||
OffsetDateTime.parse(endedAt),
|
||||
).seconds
|
||||
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
|
||||
"Measured continuous exercise duration is invalid."
|
||||
}
|
||||
/*
|
||||
* WHY: a Program occurrence starts as prescription-only, but
|
||||
* pressing Finish makes its already-recorded timeline interval
|
||||
* an actual performance fact.
|
||||
* CONTRACT: only a target-only continuous occurrence without
|
||||
* supplemental fields is materialized from its own measured
|
||||
* start/end markers; an entered fact is never overwritten.
|
||||
* INVARIANT: timeline completion and performed-duration
|
||||
* creation commit or roll back together.
|
||||
*/
|
||||
db.insertOrThrow(
|
||||
"draft_continuous_activity",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("draft_exercise_row_id", continuous.first)
|
||||
put("duration_seconds", measuredSeconds)
|
||||
},
|
||||
)
|
||||
}
|
||||
val timing =
|
||||
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
|
||||
db.setTransactionSuccessful()
|
||||
|
|
@ -1871,6 +1920,61 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: an older build could close a target-only continuous timeline while
|
||||
* leaving its performed row absent, making the preserved draft impossible
|
||||
* to finalize after upgrade.
|
||||
* CONTRACT: only completed timeline facts for continuous occurrences with
|
||||
* no supplemental fields may supply the missing performed duration.
|
||||
* INVARIANT: existing performed rows and recorded timestamps are never
|
||||
* changed; every inserted duration is positive, bounded, and transaction
|
||||
* owned by the caller.
|
||||
*/
|
||||
private fun materializeCompletedContinuousTimings(
|
||||
db: SQLiteDatabase,
|
||||
sessionId: String,
|
||||
) {
|
||||
val missing = mutableListOf<Triple<Long, String, String>>()
|
||||
db.rawQuery(
|
||||
"SELECT de.id,t.started_at,t.ended_at,de.data_fields " +
|
||||
"FROM draft_session_exercises de " +
|
||||
"JOIN active_session_draft d ON d.id=de.draft_id " +
|
||||
"JOIN session_timeline_exercises t ON t.session_id=d.session_id " +
|
||||
"AND t.entry_id=de.entry_id " +
|
||||
"LEFT JOIN draft_continuous_activity ca " +
|
||||
"ON ca.draft_exercise_row_id=de.id " +
|
||||
"WHERE d.id=1 AND d.session_id=? " +
|
||||
"AND de.recording_mode='continuous' AND t.ended_at IS NOT NULL " +
|
||||
"AND ca.draft_exercise_row_id IS NULL",
|
||||
arrayOf(sessionId),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
check(cursor.getInt(3) == 0) {
|
||||
"Continuous speed or distance is missing from a completed exercise."
|
||||
}
|
||||
missing += Triple(cursor.getLong(0), cursor.getString(1), cursor.getString(2))
|
||||
}
|
||||
}
|
||||
missing.forEach { (draftExerciseRowId, startedAt, endedAt) ->
|
||||
val measuredSeconds =
|
||||
Duration.between(
|
||||
OffsetDateTime.parse(startedAt),
|
||||
OffsetDateTime.parse(endedAt),
|
||||
).seconds
|
||||
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
|
||||
"Measured continuous exercise duration is invalid."
|
||||
}
|
||||
db.insertOrThrow(
|
||||
"draft_continuous_activity",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("draft_exercise_row_id", draftExerciseRowId)
|
||||
put("duration_seconds", measuredSeconds)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCardioCalibrationProfile(
|
||||
db: SQLiteDatabase,
|
||||
calibrationId: String,
|
||||
|
|
@ -5509,15 +5613,32 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
fun finalizeActiveSessionDraft(): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(null)
|
||||
/**
|
||||
* Finalize the active singleton atomically. An explicit offset-aware end
|
||||
* is reserved for factual recovery; null retains the live-clock behavior.
|
||||
*/
|
||||
fun finalizeActiveSessionDraft(endedAt: String? = null): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(null, endedAt)
|
||||
|
||||
/** Finalize exactly one lifecycle identity; retry after commit is idempotent. */
|
||||
fun finalizeExecutionDraft(sessionId: String): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(sessionId)
|
||||
/**
|
||||
* Finalize exactly one lifecycle identity; retry after commit is
|
||||
* idempotent. The optional factual end follows the same bounds as active
|
||||
* singleton finalization.
|
||||
*/
|
||||
fun finalizeExecutionDraft(
|
||||
sessionId: String,
|
||||
endedAt: String? = null,
|
||||
): FinalizeActiveDraftResult =
|
||||
finalizeActiveSessionDraftInternal(sessionId, endedAt)
|
||||
|
||||
private fun finalizeActiveSessionDraftInternal(expectedSessionId: String?):
|
||||
private fun finalizeActiveSessionDraftInternal(
|
||||
expectedSessionId: String?,
|
||||
requestedEndedAt: String?,
|
||||
):
|
||||
FinalizeActiveDraftResult {
|
||||
val finalizedAt = requestedEndedAt ?: OffsetDateTime.now().toString()
|
||||
val parsedFinalizedAt = TrainlogTimestamp.parse(finalizedAt)
|
||||
?: return FinalizeActiveDraftResult.Invalid("Invalid session end timestamp.")
|
||||
var db: SQLiteDatabase? = null
|
||||
var transactionOpen = false
|
||||
return try {
|
||||
|
|
@ -5536,7 +5657,7 @@ class TrainlogRepository(
|
|||
return FinalizeActiveDraftResult.Saved(expectedSessionId)
|
||||
}
|
||||
}
|
||||
val active =
|
||||
var active =
|
||||
loadActiveSessionDraft(db)
|
||||
if (active == null) {
|
||||
db.endTransaction()
|
||||
|
|
@ -5565,7 +5686,15 @@ class TrainlogRepository(
|
|||
"ACTIVE_EXERCISE:$activeEntryId",
|
||||
)
|
||||
}
|
||||
val finalizedAt = OffsetDateTime.now().toString()
|
||||
val sessionStartedAt = TrainlogTimestamp.parse(active.draft.startedAt!!)
|
||||
?: error("Active execution draft has an invalid started_at.")
|
||||
if (parsedFinalizedAt < sessionStartedAt) {
|
||||
db.endTransaction()
|
||||
transactionOpen = false
|
||||
return FinalizeActiveDraftResult.Invalid(
|
||||
"Session cannot end before it starts.",
|
||||
)
|
||||
}
|
||||
val latestExerciseEnd =
|
||||
db.rawQuery(
|
||||
"SELECT MAX(ended_at) FROM session_timeline_exercises WHERE session_id=?",
|
||||
|
|
@ -5576,7 +5705,7 @@ class TrainlogRepository(
|
|||
if (
|
||||
latestExerciseEnd != null &&
|
||||
TrainlogTimestamp.parse(latestExerciseEnd)!! >
|
||||
TrainlogTimestamp.parse(finalizedAt)!!
|
||||
parsedFinalizedAt
|
||||
) {
|
||||
db.endTransaction()
|
||||
transactionOpen = false
|
||||
|
|
@ -5585,6 +5714,11 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
materializeCompletedContinuousTimings(db, lifecycleSessionId)
|
||||
active = checkNotNull(loadActiveSessionDraft(db)) {
|
||||
"Active execution draft disappeared during finalization."
|
||||
}
|
||||
|
||||
val completed =
|
||||
SessionDraft(
|
||||
exercises =
|
||||
|
|
@ -5621,12 +5755,47 @@ class TrainlogRepository(
|
|||
"WHERE session_id=? AND ended_at IS NULL",
|
||||
arrayOf(finalizedAt, sessionId),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
||||
"active_exercise_entry_id=NULL WHERE active_slot=1 AND context_id=? " +
|
||||
"AND context_kind IN('session','cardio')",
|
||||
arrayOf(finalizedAt, sessionId),
|
||||
)
|
||||
val activeCapture =
|
||||
db.rawQuery(
|
||||
"SELECT capture_id FROM heart_rate_captures " +
|
||||
"WHERE active_slot=1 AND context_id=? " +
|
||||
"AND context_kind IN('session','cardio')",
|
||||
arrayOf(sessionId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (activeCapture != null) {
|
||||
var captureEndedAt = finalizedAt
|
||||
var captureEndKey = parsedFinalizedAt
|
||||
db.rawQuery(
|
||||
"SELECT observed_at FROM heart_rate_samples WHERE capture_id=?",
|
||||
arrayOf(activeCapture),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val observedAt = cursor.getString(0)
|
||||
val observedKey = TrainlogTimestamp.parse(observedAt)
|
||||
?: error("Heart-rate sample has an invalid timestamp.")
|
||||
if (observedKey > captureEndKey) {
|
||||
captureEndKey = observedKey
|
||||
captureEndedAt = observedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* WHY: delayed recovery can establish a factual session end
|
||||
* earlier than samples recorded while the failed draft stayed
|
||||
* open. Dropping or retiming those immutable measurements
|
||||
* would be data loss.
|
||||
* CONTRACT: the session/timeline use the requested factual
|
||||
* end; the sensor capture expands only to its latest observed
|
||||
* sample so Heart Rate V1 remains internally valid.
|
||||
* INVARIANT: every retained sample lies within its stopped
|
||||
* capture and no sample/RR identity or payload is rewritten.
|
||||
*/
|
||||
db.execSQL(
|
||||
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
||||
"active_exercise_entry_id=NULL WHERE capture_id=? AND active_slot=1",
|
||||
arrayOf(captureEndedAt, activeCapture),
|
||||
)
|
||||
}
|
||||
closeCardioGuidanceRunForSession(db, sessionId, finalizedAt)
|
||||
|
||||
/* INVARIANT: completion moves the exact Program provenance from
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import androidx.compose.ui.semantics.semantics
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.BuildConfig
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
|
|
@ -75,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
/**
|
||||
* CONTRACT: a private recovery build may override finalization time for one
|
||||
* exact stable lifecycle identity only. Empty build inputs and every other
|
||||
* draft retain the normal repository clock default.
|
||||
*/
|
||||
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
|
||||
it.isNotEmpty()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionScreen(
|
||||
|
|
@ -638,7 +651,9 @@ fun SessionScreen(
|
|||
} else {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
repository.finalizeActiveSessionDraft(
|
||||
recoveryEndedAt(currentDraft),
|
||||
)
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
|
|
@ -870,7 +885,11 @@ fun SessionScreen(
|
|||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
timelineRevision++
|
||||
when (val result = repository.finalizeActiveSessionDraft()) {
|
||||
when (
|
||||
val result = repository.finalizeActiveSessionDraft(
|
||||
recoveryEndedAt(currentDraft),
|
||||
)
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
pendingFinalizeActiveEntryId = null
|
||||
onSessionSaved()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionSetDraft
|
|||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
|
|
@ -178,4 +179,280 @@ class SessionTimelineRepositoryTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
|
||||
val name = "program-session-finish-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val programId = "pg_11111111-1111-4111-8111-111111111111"
|
||||
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
|
||||
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
|
||||
val rowId = "ex_44444444-4444-4444-8444-444444444444"
|
||||
val pressId = "ex_55555555-5555-4555-8555-555555555555"
|
||||
val curlId = "ex_66666666-6666-4666-8666-666666666666"
|
||||
|
||||
fun catalogExercise(
|
||||
exerciseId: String,
|
||||
exerciseName: String,
|
||||
recordingMode: String,
|
||||
trackingMode: String,
|
||||
) =
|
||||
JSONObject()
|
||||
.put("exercise_id", exerciseId)
|
||||
.put("name", exerciseName)
|
||||
.put("recording_mode", recordingMode)
|
||||
.put("tracking_mode", trackingMode)
|
||||
.put("data_fields", 0)
|
||||
|
||||
val catalog =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put(
|
||||
"exercises",
|
||||
JSONArray()
|
||||
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
|
||||
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
|
||||
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
|
||||
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
|
||||
)
|
||||
.toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
|
||||
fun occurrence(
|
||||
entryId: String,
|
||||
exerciseId: String,
|
||||
targetSets: Int?,
|
||||
targetReps: Int?,
|
||||
targetDuration: Int?,
|
||||
targetWeight: Double?,
|
||||
restSeconds: Int,
|
||||
) =
|
||||
JSONObject()
|
||||
.put("entry_id", entryId)
|
||||
.put("exercise_id", exerciseId)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("load_mode", if (targetWeight == null) "none" else "external")
|
||||
.put("rest_seconds", restSeconds)
|
||||
.put("target_sets", targetSets ?: JSONObject.NULL)
|
||||
.put("target_reps", targetReps ?: JSONObject.NULL)
|
||||
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
|
||||
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
|
||||
.put("notes", JSONObject.NULL)
|
||||
|
||||
val program =
|
||||
JSONObject()
|
||||
.put("program_id", programId)
|
||||
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
|
||||
.put("title", "Session finalization regression")
|
||||
.put("note", JSONObject.NULL)
|
||||
.put("state", "active")
|
||||
.put("start_date", "2026-09-24")
|
||||
.put("end_date", JSONObject.NULL)
|
||||
.put("created_at", "2026-09-24T08:00:00Z")
|
||||
.put("updated_at", "2026-09-24T08:00:00Z")
|
||||
.put("source_format", "trainlog-program")
|
||||
.put("source_version", 1)
|
||||
.put("source_payload_sha256", "a".repeat(64))
|
||||
.put(
|
||||
"sessions",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("program_session_id", programSessionId)
|
||||
.put("title", "Workout")
|
||||
.put("session_type", "training")
|
||||
.put("planned_for", "2026-09-24")
|
||||
.put("note", JSONObject.NULL)
|
||||
.put(
|
||||
"occurrences",
|
||||
JSONArray()
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_88888888-8888-4888-8888-888888888888",
|
||||
warmupId,
|
||||
null,
|
||||
null,
|
||||
480,
|
||||
null,
|
||||
0,
|
||||
),
|
||||
)
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_99999999-9999-4999-8999-999999999999",
|
||||
rowId,
|
||||
3,
|
||||
10,
|
||||
null,
|
||||
39.0,
|
||||
90,
|
||||
),
|
||||
)
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
pressId,
|
||||
3,
|
||||
10,
|
||||
null,
|
||||
32.0,
|
||||
90,
|
||||
),
|
||||
)
|
||||
.put(
|
||||
occurrence(
|
||||
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
curlId,
|
||||
2,
|
||||
10,
|
||||
null,
|
||||
23.0,
|
||||
60,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val programs =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-programs")
|
||||
.put("version", 1)
|
||||
.put("generated_at", "2026-09-24T08:01:00Z")
|
||||
.put("programs", JSONArray().put(program))
|
||||
.put("deletions", JSONArray())
|
||||
.toString()
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(programs),
|
||||
)
|
||||
assertEquals(
|
||||
StartProgramSessionResult.Started,
|
||||
repository.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
|
||||
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val sessionId = checkNotNull(opened.draft.sessionId)
|
||||
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
|
||||
val warmup = opened.draft.exercises[0]
|
||||
val row = opened.draft.exercises[1]
|
||||
val press = opened.draft.exercises[2]
|
||||
val curl = opened.draft.exercises[3]
|
||||
val performed =
|
||||
opened.draft.copy(
|
||||
exercises =
|
||||
listOf(
|
||||
warmup,
|
||||
row.copy(
|
||||
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
|
||||
),
|
||||
press.copy(
|
||||
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
|
||||
),
|
||||
curl.copy(
|
||||
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||
|
||||
val warmupStart = sessionStart.plusSeconds(10)
|
||||
val warmupEnd = warmupStart.plusSeconds(433)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
|
||||
/* Recreate the exact v49 persisted state: its timeline was closed,
|
||||
* but the target-only continuous occurrence had no performed row. */
|
||||
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
|
||||
db.execSQL(
|
||||
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
|
||||
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
|
||||
arrayOf(warmup.entryId),
|
||||
)
|
||||
}
|
||||
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
|
||||
|
||||
listOf(row, press, curl).forEachIndexed { index, exercise ->
|
||||
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
|
||||
val endedAt = startedAt.plusSeconds(60)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
}
|
||||
assertTrue(
|
||||
repository.saveDraftExerciseFeedback(
|
||||
curl.entryId,
|
||||
"Valid effort feedback",
|
||||
warmupEnd.plusSeconds(400).toString(),
|
||||
) is SaveFeedbackResult.Saved,
|
||||
)
|
||||
|
||||
val measurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 107,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(620),
|
||||
)
|
||||
val finalizationAt = warmupEnd.plusSeconds(500)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
finalizationAt.minusSeconds(1).toString(),
|
||||
measurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val lateSampleAt = finalizationAt.plusSeconds(10)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
lateSampleAt.toString(),
|
||||
measurement.copy(bpm = 99),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
|
||||
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
|
||||
assertEquals(
|
||||
sessionId,
|
||||
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
|
||||
)
|
||||
val detail = checkNotNull(repository.getSessionDetail(sessionId))
|
||||
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
|
||||
assertEquals(4, detail.exercises.size)
|
||||
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
|
||||
assertEquals(2, detail.exercises[3].plan?.sets)
|
||||
assertEquals(3, detail.exercises[3].sets.size)
|
||||
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
|
||||
assertEquals(1, detail.exercises[3].feedback.size)
|
||||
|
||||
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
|
||||
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
|
||||
assertEquals(4, timelineSession.getJSONArray("exercises").length())
|
||||
|
||||
val heart = JSONObject(repository.buildHeartRateV1Json())
|
||||
val capture = heart.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(lateSampleAt.toString(), capture.getString("ended_at"))
|
||||
assertEquals(2, capture.getJSONArray("samples").length())
|
||||
assertEquals(1, capture.getJSONArray("samples").getJSONObject(1)
|
||||
.getJSONArray("rr_intervals_1024").length())
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -678,7 +678,22 @@ resume action after relaunch.
|
|||
Final save validates the durable draft, inserts the completed session and actual
|
||||
values, and removes the draft in one SQLite transaction. Failure rolls back and
|
||||
retains the draft for retry; repeated completion does not create duplicates.
|
||||
The existing completed-session save-time timestamp behavior is unchanged.
|
||||
Planned targets remain prescriptions: finalization accepts fewer or more valid
|
||||
performed sets and actual repetitions or loads that differ from their targets.
|
||||
Finishing a target-only continuous occurrence with no supplemental fields
|
||||
materializes its performed duration from that occurrence's already persisted
|
||||
timeline start/end interval. This also repairs a preserved draft whose interval
|
||||
was closed by an older build without creating the performed row. Existing
|
||||
performed values are never overwritten, and continuous occurrences that require
|
||||
speed or distance still require those measured values explicitly.
|
||||
|
||||
Ordinary completion uses the save-time timestamp. A private recovery build may
|
||||
embed one exact session identity and one explicit offset-aware factual end time;
|
||||
only that matching draft uses the supplied time. The repository rejects an end
|
||||
before the session start or latest exercise marker. When immutable heart-rate
|
||||
samples arrived after a failed factual session end, the session retains the
|
||||
factual end while the stopped capture remains bounded by its latest retained
|
||||
sample; no BPM or RR measurement is deleted or retimed.
|
||||
|
||||
PC catalog reconciliation preserves draft references through catalog row
|
||||
ownership. If an editing selection no longer resolves, only the selection is
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ occurrences: opening an existing stable entry renders its prefilled form,
|
|||
keeps the independent target intact on save, and leaves sibling occurrences
|
||||
unchanged on save or cancellation. A set-based target-only occurrence can add
|
||||
or change equipment without fabricating a performed set.
|
||||
Session completion now also turns a completed target-only continuous timeline
|
||||
into its measured performed duration when no supplemental field is required.
|
||||
Program targets remain independent from facts, so a valid execution may contain
|
||||
fewer or more sets and different repetitions or loads. A strictly session-bound
|
||||
private recovery build can supply a past factual end without retiming exercise
|
||||
markers or immutable heart-rate samples.
|
||||
|
||||
The full-generation path is operational on the paired daily installation.
|
||||
Bluetooth Classic is the primary local byte transport, MTP remains the wired
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@ execution and verifies Web `1 / 24` presentation. Related regressions cover
|
|||
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
||||
non-root current revisions.
|
||||
|
||||
Android session-finalization coverage includes a Program-derived mixed session
|
||||
whose continuous target is completed from its measured timeline, several prior
|
||||
occurrences, feedback, an active heart-rate capture, and a planned `2 x 10`
|
||||
occurrence performed as three valid `10 @ 23 kg` sets. It recreates the legacy
|
||||
state with a closed continuous timeline but no performed row, finalizes at an
|
||||
explicit factual timestamp, and proves that plan, actual sets, feedback,
|
||||
timeline, BPM samples, and RR intervals remain distinct and intact. Desktop
|
||||
Sleep exchange coverage also verifies that replay of an identical durable but
|
||||
unpublished revision acknowledges it without `StopIteration`, while different
|
||||
content under the same revision identity remains an explicit conflict.
|
||||
|
||||
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
|
||||
|
|
|
|||
|
|
@ -251,6 +251,35 @@ class SleepExchangeTest(unittest.TestCase):
|
|||
(item["entry_id"], revision, item["updated_at"]))
|
||||
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
|
||||
|
||||
def test_identical_remote_revision_validates_unpublished_local_draft(self):
|
||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||
document = self.document(entry(revision))
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, document))
|
||||
self.db.execute(
|
||||
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||
(document["entries"][0]["entry_id"],),
|
||||
)
|
||||
|
||||
self.assertEqual([], exchange.build(self.db)["entries"])
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, document))
|
||||
self.assertEqual(
|
||||
revision,
|
||||
self.db.execute(
|
||||
"SELECT acknowledged_revision_id FROM sleep_diary_publication_state "
|
||||
"WHERE entry_id=?",
|
||||
(document["entries"][0]["entry_id"],),
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
self.db.execute(
|
||||
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||
(document["entries"][0]["entry_id"],),
|
||||
)
|
||||
conflicting = json.loads(json.dumps(document))
|
||||
conflicting["entries"][0]["treatment_and_notes"] = "Different content"
|
||||
with self.assertRaisesRegex(ValueError, "identity reused"):
|
||||
exchange.apply(self.db, conflicting)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -217,8 +217,50 @@ def revision_ancestry(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
|||
return result
|
||||
|
||||
|
||||
def _persisted_entry(db: sqlite3.Connection, row: tuple) -> dict:
|
||||
"""Build one durable entry independently of its publication state."""
|
||||
entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted = row
|
||||
revision = db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
||||
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
||||
).fetchone()
|
||||
if revision is None:
|
||||
fail("sleep diary current revision is missing")
|
||||
ancestry = revision_ancestry(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_diary_revisions "
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
entry_id,
|
||||
revision_id,
|
||||
REVISION_ID,
|
||||
)
|
||||
events = [
|
||||
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
|
||||
for value in db.execute(
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
intakes = [
|
||||
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
|
||||
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
|
||||
"quantity": value[6], "note": value[7], "created_at": value[8]}
|
||||
for value in db.execute(
|
||||
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
|
||||
"quantity,note,created_at FROM sleep_medication_intakes "
|
||||
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
return {
|
||||
"entry_id": entry_id, "night_start_date": night_start, "night_end_date": night_end,
|
||||
"created_at": created_at, "updated_at": updated_at, "revision_id": revision_id,
|
||||
"parent_revision_id": revision[0], "ancestry": ancestry, "deleted": bool(deleted),
|
||||
"sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
||||
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes,
|
||||
}
|
||||
|
||||
|
||||
def build(db: sqlite3.Connection) -> dict:
|
||||
entries = []
|
||||
# CONTRACT: local durability does not publish a draft. A generation may
|
||||
# capture only the current tip explicitly validated by the user.
|
||||
rows = db.execute(
|
||||
|
|
@ -229,37 +271,7 @@ def build(db: sqlite3.Connection) -> dict:
|
|||
).fetchall()
|
||||
if len(rows) > MAX_ENTRIES:
|
||||
fail("sleep diary entry bound exceeded")
|
||||
for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows:
|
||||
revision = db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
||||
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
||||
).fetchone()
|
||||
if revision is None:
|
||||
fail("sleep diary current revision is missing")
|
||||
ancestry = revision_ancestry(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_diary_revisions "
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
entry_id,
|
||||
revision_id,
|
||||
REVISION_ID,
|
||||
)
|
||||
events = [
|
||||
{"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]}
|
||||
for row in db.execute(
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
intakes = [{"intake_id": row[0], "medication_id": row[1], "medication_name": row[2],
|
||||
"taken_at": row[3], "dose_value": row[4], "dose_unit": row[5],
|
||||
"quantity": row[6], "note": row[7], "created_at": row[8]}
|
||||
for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))]
|
||||
entries.append({"entry_id": entry_id, "night_start_date": night_start,
|
||||
"night_end_date": night_end, "created_at": created_at, "updated_at": updated_at,
|
||||
"revision_id": revision_id, "parent_revision_id": revision[0], "ancestry": ancestry,
|
||||
"deleted": bool(deleted), "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
||||
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes})
|
||||
entries = [_persisted_entry(db, row) for row in rows]
|
||||
medications = []
|
||||
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
|
||||
ancestry = revision_ancestry(
|
||||
|
|
@ -347,8 +359,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
|||
(item["entry_id"],),
|
||||
).fetchone()
|
||||
if local is not None and local[0] == item["revision_id"]:
|
||||
persisted = next(
|
||||
value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"]
|
||||
# INVARIANT: an identical durable revision may still be a local
|
||||
# draft because publication state is intentionally separate. The
|
||||
# incoming validated snapshot must compare the durable content
|
||||
# directly before it acknowledges that revision.
|
||||
persisted = _persisted_entry(
|
||||
db,
|
||||
db.execute(
|
||||
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,"
|
||||
"current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(item["entry_id"],),
|
||||
).fetchone(),
|
||||
)
|
||||
persisted_payload = dict(persisted)
|
||||
incoming_payload = dict(item)
|
||||
|
|
|
|||
Loading…
Reference in a new issue