From e2284be1e3ffdc0879f90b2bdfbb49a76ff73171 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 21 Sep 2026 13:34:43 +0200 Subject: [PATCH] fix(android): start mixed session preparations --- CHANGELOG.md | 4 + .../trainlog/data/TrainlogRepository.kt | 99 +++++++++-------- .../data/SessionPreparationExchangeTest.kt | 100 ++++++++++++++++++ docs/current_state.md | 4 + 4 files changed, 162 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48915bf..4654204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased — 0.1.4 +- Fixed Android activation of mixed session preparations containing continuous + duration targets and set-based exercises. Continuous target metadata now + remains planning-only, SQL `NULL` set counts round-trip correctly, and no + performed continuous activity or set is created before user capture. - Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse, full-generation synchronization and a deterministic local vector PDF agenda. Entries retain causal revisions, absolute timestamped events, the five-value 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 09b0b00..818e80d 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 @@ -7838,6 +7838,8 @@ class TrainlogRepository( exercise.recordingMode == RecordingMode.CONTINUOUS ) { + /* CONTRACT: a target-only continuous occurrence has no + * performed row until the user records actual activity. */ val continuous = db.query( "draft_continuous_activity", @@ -7852,30 +7854,21 @@ class TrainlogRepository( null, null, ).use { item -> - check(item.moveToFirst()) { - "Activité continue du brouillon manquante." - } - - SessionExerciseDraft( + val base = SessionExerciseDraft( entryId = entryId, exercise = exercise, equipmentId = equipmentId, plan = plan, - continuousDurationSeconds = - item.getInt(0), - speedKmh = - if (item.isNull(1)) { - null - } else { - item.getDouble(1) - }, - distanceKm = - if (item.isNull(2)) { - null - } else { - item.getDouble(2) - }, ) + if (!item.moveToFirst()) { + base + } else { + base.copy( + continuousDurationSeconds = item.getInt(0), + speedKmh = if (item.isNull(1)) null else item.getDouble(1), + distanceKm = if (item.isNull(2)) null else item.getDouble(2), + ) + } } exercises += continuous } else { @@ -8107,28 +8100,35 @@ class TrainlogRepository( exerciseDraft.exercise.recordingMode == RecordingMode.CONTINUOUS ) { - val continuousValues = - ContentValues().apply { - put( - "draft_exercise_row_id", - draftExerciseRowId, - ) - put( - "duration_seconds", - exerciseDraft.continuousDurationSeconds, - ) - exerciseDraft.speedKmh?.let { - put("speed_kmh", it) + /* + * A continuous target is planning data, not performed data. + * Do not manufacture a performed activity when a planned draft + * is first activated. + */ + if (exerciseDraft.continuousDurationSeconds > 0) { + val continuousValues = + ContentValues().apply { + put( + "draft_exercise_row_id", + draftExerciseRowId, + ) + put( + "duration_seconds", + exerciseDraft.continuousDurationSeconds, + ) + exerciseDraft.speedKmh?.let { + put("speed_kmh", it) + } + exerciseDraft.distanceKm?.let { + put("distance_km", it) + } } - exerciseDraft.distanceKm?.let { - put("distance_km", it) - } - } - db.insertOrThrow( - "draft_continuous_activity", - null, - continuousValues, - ) + db.insertOrThrow( + "draft_continuous_activity", + null, + continuousValues, + ) + } } else { exerciseDraft.sets.forEachIndexed { setIndex, @@ -9168,7 +9168,10 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) { putNull("target_duration_seconds") putNull("target_weight_kg") } else { - put("target_sets", plan.sets) + /* CONTRACT: continuous plans have no set count. The in-memory model + * uses zero for that absence, while the durable schema uses NULL and + * reserves positive target_sets for set-based occurrences. */ + if (plan.sets == 0) putNull("target_sets") else put("target_sets", plan.sets) if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps) if (plan.durationSeconds == null) putNull("target_duration_seconds") else put("target_duration_seconds", plan.durationSeconds) @@ -9179,15 +9182,21 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) { private fun readSessionPlan(cursor: Cursor, start: Int): SessionExercisePlan? { val loadMode = SessionLoadMode.fromWire(cursor.getString(start)) val restSeconds = cursor.getInt(start + 1) - if (cursor.isNull(start + 2)) { - check(loadMode == SessionLoadMode.NONE && restSeconds == 0 && - cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) { + val hasTarget = + !cursor.isNull(start + 2) || + !cursor.isNull(start + 3) || + !cursor.isNull(start + 4) || + !cursor.isNull(start + 5) + if (!hasTarget) { + check(loadMode == SessionLoadMode.NONE && restSeconds == 0) { "Métadonnées de plan cible incohérentes" } return null } + /* INVARIANT: SQL NULL target_sets is preserved as the model's zero + * marker only when other target data proves that a plan exists. */ return SessionExercisePlan( - sets = cursor.getInt(start + 2), + sets = if (cursor.isNull(start + 2)) 0 else cursor.getInt(start + 2), reps = if (cursor.isNull(start + 3)) null else cursor.getInt(start + 3), durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4), weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 53b5fe6..92ba012 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -105,6 +105,106 @@ class SessionPreparationExchangeTest { } } + @Test + fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() { + val name = "prepared-mixed-${System.nanoTime()}.db" + val repository = TrainlogRepository(context, name) + try { + assertTrue( + repository.createExercise( + NewExerciseProfile( + name = "Test prepared walk", + recordingMode = RecordingMode.CONTINUOUS, + trackingMode = TrackingMode.DURATION, + dataFields = 0, + ), + ) is CreateExerciseResult.Created, + ) + assertTrue( + repository.createExercise( + NewExerciseProfile( + name = "Test prepared press", + recordingMode = RecordingMode.SETS, + trackingMode = TrackingMode.REPS, + dataFields = 0, + ), + ) is CreateExerciseResult.Created, + ) + val continuous = repository.listExercises().single { it.name == "Test prepared walk" } + val sets = repository.listExercises().single { it.name == "Test prepared press" } + val payload = JSONObject(artifact(repository)) + val delivery = payload.getJSONArray("deliveries").getJSONObject(0) + delivery.put( + "occurrences", + JSONArray() + .put( + JSONObject() + .put("entry_id", "spe_11111111-1111-4111-8111-111111111111") + .put("position", 0) + .put("exercise_id", continuous.exerciseId) + .put("equipment_id", JSONObject.NULL) + .put("recording_mode", "continuous") + .put("tracking_mode", "duration") + .put("data_fields", continuous.dataFields) + .put("load_mode", "none") + .put("rest_seconds", 0) + .put("target_sets", JSONObject.NULL) + .put("target_reps", JSONObject.NULL) + .put("target_duration_seconds", 600) + .put("target_weight_kg", JSONObject.NULL) + .put("notes", "Target only"), + ) + .put( + JSONObject() + .put("entry_id", "spe_22222222-2222-4222-8222-222222222222") + .put("position", 1) + .put("exercise_id", sets.exerciseId) + .put("equipment_id", JSONObject.NULL) + .put("recording_mode", "sets") + .put("tracking_mode", "reps") + .put("data_fields", sets.dataFields) + .put("load_mode", "external") + .put("rest_seconds", 90) + .put("target_sets", 3) + .put("target_reps", 10) + .put("target_duration_seconds", JSONObject.NULL) + .put("target_weight_kg", 52.0) + .put("notes", JSONObject.NULL), + ), + ) + + assertEquals( + AiSessionDraftImportResult.Applied(1, 0), + repository.applySessionPreparationsJson(payload.toString()), + ) + assertEquals( + StartAiSessionDraftResult.Started, + repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), + ) + val loadResult = repository.loadActiveSessionDraft() + assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded) + val active = loadResult as ActiveDraftLoadResult.Loaded + assertEquals(2, active.draft.exercises.size) + assertEquals(600, active.draft.exercises[0].plan?.durationSeconds) + assertEquals(0, active.draft.exercises[0].continuousDurationSeconds) + assertEquals(3, active.draft.exercises[1].plan?.sets) + assertEquals(10, active.draft.exercises[1].plan?.reps) + assertEquals(52.0, active.draft.exercises[1].plan?.weightKg) + assertTrue(active.draft.exercises[1].sets.isEmpty()) + assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft")) + assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity")) + assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets")) + assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results")) + assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback")) + assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions")) + assertEquals("ok", scalarText(name, "PRAGMA integrity_check")) + assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check")) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + @Test fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() { val name = "prepared-${System.nanoTime()}.db" diff --git a/docs/current_state.md b/docs/current_state.md index 992c15c..5f5697e 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -45,6 +45,10 @@ and an accessible send action. Draft/local advances the same preparation to a ready revision before delivery; ready/local delivers the current revision. Completed history hides the active preparation exclusively through the delivery's stable execution-session identity and retains all provenance. +Starting a delivered mixed preparation preserves continuous duration targets +and set/repetition/load/rest targets in the active singleton without creating +performed continuous activity, performed sets, MAX results, history, or +feedback before explicit user capture. The opt-in full-generation path is operational on the private daily installation. Android explicit synchronization uses a dedicated coordination