diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e900c..2dcccec 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ffde25b..1a4be6c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 51e89d8..dc5d17e 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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>() + 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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 5e4f0bd..98e2979 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -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() diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt index 6af23f8..d5731af 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionTimelineRepositoryTest.kt @@ -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) + } + } } diff --git a/docs/android.md b/docs/android.md index f74e988..51d7d7c 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index 42b07f3..c410fa4 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 diff --git a/docs/tests.md b/docs/tests.md index 097f5b8..d747738 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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 diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index 6ecbb4e..34e412c 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -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() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 888a193..1f64313 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -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)