fix(android): recover continuous session finalization

This commit is contained in:
fy59 2026-09-24 13:29:30 +02:00
parent 8646422bef
commit a652644e24
10 changed files with 648 additions and 53 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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()

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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)