From f2fd610b4f8b74b6d6ce9eb82bd75c15c75a2354 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 20:48:06 +0200 Subject: [PATCH] feat(sleep): complete pre-bed capture and multi-night cardio review --- CHANGELOG.md | 25 +++ .../trainlog/data/AndroidBackupService.kt | 4 +- .../trainlog/data/SyncGenerationService.kt | 2 +- .../trainlog/data/TrainlogRepository.kt | 192 +++++++++++++++--- .../trainlog/model/SleepDiaryModels.kt | 2 +- .../trainlog/ui/SleepDiaryScreen.kt | 65 ++++-- .../data/AndroidV10PlanningMigrationTest.kt | 2 +- .../AndroidV11ExerciseAliasMigrationTest.kt | 2 +- .../AndroidV16AiSessionDraftMigrationTest.kt | 2 +- ...AndroidV25ProgramExecutionMigrationTest.kt | 2 +- .../data/AndroidV26SleepMigrationTest.kt | 2 +- .../data/AndroidV27HeartRateMigrationTest.kt | 2 +- .../AndroidV28SessionTimelineMigrationTest.kt | 2 +- .../AndroidV29CardioSessionMigrationTest.kt | 2 +- ...ndroidV30CardioCalibrationMigrationTest.kt | 2 +- .../AndroidV31CardioGuidanceMigrationTest.kt | 2 +- .../AndroidV32SleepQuantityMigrationTest.kt | 96 +++++++++ .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../data/SessionPreparationExchangeTest.kt | 2 +- .../trainlog/data/SleepQuickCaptureTest.kt | 99 ++++++++- .../data/SyncGenerationServiceTest.kt | 2 +- .../data/TrainlogRepositoryDraftTest.kt | 4 +- docs/android.md | 26 ++- docs/current_state.md | 19 +- docs/database.md | 14 ++ docs/roadmap.md | 10 +- docs/sync_exchange.md | 40 ++-- docs/tests.md | 35 ++++ tests/test_sleep_diary_exchange.py | 56 ++++- tests/test_sync_deployment_tools.py | 6 +- tools/cardio_calibration_exchange.py | 4 +- tools/cardio_guidance_exchange.py | 4 +- tools/causal_delete_exchange.py | 4 +- tools/execution_draft_exchange.py | 4 +- tools/export_ai_history.py | 4 +- tools/export_ai_session_drafts.py | 4 +- tools/export_equipment_associations.py | 4 +- tools/export_equipment_definitions.py | 4 +- tools/export_exercise_aliases.py | 4 +- tools/export_exercise_body_zones.py | 4 +- tools/export_exercise_profile_state.py | 2 +- tools/export_pc_catalog.py | 2 +- tools/export_pc_mobile.py | 6 +- tools/export_programs.py | 2 +- tools/export_session_preparations.py | 2 +- tools/export_training_feedback.py | 4 +- tools/heart_rate_exchange.py | 4 +- tools/import_ai_session_draft.py | 4 +- tools/import_equipment_associations.py | 4 +- tools/import_equipment_definitions.py | 4 +- tools/import_exercise_aliases.py | 4 +- tools/import_exercise_body_zones.py | 4 +- tools/import_exercise_profile_state.py | 4 +- tools/import_mobile_export.py | 8 +- tools/import_training_feedback.py | 4 +- tools/package_sync_candidate.py | 6 +- tools/program_execution_exchange.py | 2 +- tools/session_timeline_exchange.py | 4 +- tools/sleep_diary_exchange.py | 27 ++- tools/sync_generation_exchange.py | 8 +- tui/include/trainlog/database.h | 2 +- tui/include/trainlog/sleep_diary.h | 1 + tui/src/database.c | 75 +++++-- tui/src/sleep_diary.c | 22 +- tui/src/web_heart_rate.c | 131 ++++++------ tui/src/web_sleep.c | 5 + tui/tests/test_schema_v29_migration.c | 4 +- tui/tests/test_schema_v30_migration.c | 23 ++- tui/tests/test_schema_v31_migration.c | 20 +- tui/tests/test_schema_v32_migration.c | 41 ++-- tui/tests/test_schema_v33_migration.c | 26 ++- tui/tests/test_schema_v34_migration.c | 47 ++++- tui/tests/test_schema_v7_migration.c | 8 +- tui/tests/test_schema_v9_migration.c | 2 +- tui/tests/test_sleep_diary.c | 1 + tui/tests/test_web_heart_rate.c | 87 +++++--- web/src/api/sleepDiary.ts | 4 + web/src/components/HeartRateTimelinePanel.tsx | 20 +- web/src/components/heartRateVariation.test.ts | 77 +++++++ web/src/components/heartRateVariation.ts | 74 +++++++ web/src/report/sleepDiaryPdf.test.ts | 7 + web/src/report/sleepDiaryPdf.ts | 2 +- web/src/routes/SleepDiaryWorkspace.test.tsx | 59 +++++- web/src/routes/SleepDiaryWorkspace.tsx | 58 +++++- web/src/theme/app.css | 30 ++- 85 files changed, 1356 insertions(+), 336 deletions(-) create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt create mode 100644 web/src/components/heartRateVariation.test.ts create mode 100644 web/src/components/heartRateVariation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dca5197..ac6016a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ ## Unreleased — 0.1.5 +- Completed the software-validated Sleep Tonight readiness delta without a + device-rollout or stable-release claim. Pre-bed medication now creates or + reuses one durable pending 18:00-to-18:00 Sleep entry; Couché appends bedtime + to that same stable identity, while Réveil/Levé remain unavailable before + bedtime. Pending quick facts are publishable through a distinct marker and + are not day validation. +- Added structured per-intake medication quantity (`1..99`) in Android schema + v32 and desktop schema v35. The active `trainlog-sleep-diary` V2 companion + (`sleep-diary-v2.json`) carries quantity; frozen V1 remains readable and + interprets missing quantity as one. Per-unit dose/unit remain separate from + quantity, which is rendered in Android, Web and PDF as appropriate (for + example `75 mg ×2`). +- Kept sleep HR capture factual and sample-driven: pre-bed medication alone + creates no capture; the first real post-Couché sample opens/reuses the + entry-owned capture at bedtime; Réveil does not split it; and Levé closes it + exactly at final get-up. A no-sensor night fabricates neither capture nor + sample. +- Updated the Web Sleep agenda to render every returned night in the selected + period on one shared 18:00-to-18:00 axis. Row selection is navigation only + and shows the selected entry’s full-width HR detail. Relative measured HR + rises/falls use a trailing five-minute reference excluding the current + 60-second window, thresholds of `max(10 BPM, 15%)`, and ignore gaps over + three seconds; labels remain descriptive and make no sleep-stage, medical, + or causal inference. + - Added the Cardio V1 persistence/synchronization foundation. Android schema v27 owns durable timestamped heart-rate captures, samples and raw RR values; desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 4a587e6..7f2fca9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -225,7 +225,7 @@ internal class AndroidBackupService( manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..31 + manifest.getInt("schema") in 17..32 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..31) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..32) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt index 576c0d9..4cab9ce 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt @@ -70,7 +70,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository) Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"), "program-executions" to Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), - "sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false), + "sleep-diary" to Kind("trainlog-sleep-diary", 2, "sleep-diary-v2.json", false), "heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false), "cardio-calibrations" to Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false), 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 85679f8..bb7ae2d 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 @@ -662,6 +662,7 @@ class TrainlogRepository( OffsetDateTime.parse(intake.takenAt) OffsetDateTime.parse(intake.createdAt) intake.medicationId.startsWith("med_") && intake.medicationName.isNotBlank() && + intake.quantity in 1..99 && ((intake.doseValue == null && intake.doseUnit == null) || (intake.doseValue != null && intake.doseValue > 0.0 && !intake.doseUnit.isNullOrBlank())) @@ -670,7 +671,13 @@ class TrainlogRepository( /** CONTRACT: one transaction appends an immutable revision and advances * the guarded tip. Wall time never resolves a concurrent edit. */ - fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult { + fun saveSleepDiary(draft: SleepDiaryDraft): SaveSleepDiaryResult = + saveSleepDiary(draft, quickPublicationAt = null) + + private fun saveSleepDiary( + draft: SleepDiaryDraft, + quickPublicationAt: String?, + ): SaveSleepDiaryResult { if (!sleepDraftValid(draft)) return SaveSleepDiaryResult.Invalid val creating = draft.entryId == null && draft.expectedRevision == null if (!creating && (draft.entryId == null || draft.expectedRevision == null)) return SaveSleepDiaryResult.Invalid @@ -701,11 +708,39 @@ class TrainlogRepository( arrayOf(revisionId,event.eventId.ifEmpty { sleepId("sle") },event.type.wireValue,event.startAt,event.endAt), ) } draft.intakes.forEach { intake -> db.execSQL( - "INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)", + "INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)", arrayOf(revisionId, intake.intakeId.ifEmpty { sleepId("mdi") }, intake.medicationId, intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit, + intake.quantity, intake.note, intake.createdAt), ) } + if (quickPublicationAt != null) { + /* WHY: generation may publish a quick fact before day validation. + * CONTRACT: the immutable revision, guarded current tip, and its + * exact quick-publication marker commit or roll back together. */ + db.execSQL( + "INSERT OR IGNORE INTO sleep_diary_publication_state(entry_id," + + "validated_revision_id,validated_at,acknowledged_revision_id," + + "acknowledged_at) SELECT entry_id,NULL,NULL,NULL,NULL FROM " + + "sleep_diary_entries WHERE entry_id=? AND current_revision_id=?", + arrayOf(entryId, revisionId), + ) + val marked = db.compileStatement( + "INSERT INTO sleep_diary_quick_publication_state(entry_id," + + "publishable_revision_id,published_at) SELECT entry_id,?,? FROM " + + "sleep_diary_entries WHERE entry_id=? AND current_revision_id=? " + + "ON CONFLICT(entry_id) DO UPDATE SET " + + "publishable_revision_id=excluded.publishable_revision_id," + + "published_at=excluded.published_at", + ) + marked.bindString(1, revisionId) + marked.bindString(2, quickPublicationAt) + marked.bindString(3, entryId) + marked.bindString(4, revisionId) + if (marked.executeInsert() < 0) throw android.database.sqlite.SQLiteException( + "Quick Sleep publication tip invariant failed", + ) + } db.setTransactionSuccessful() SaveSleepDiaryResult.Saved(entryId,revisionId) } catch (_: android.database.sqlite.SQLiteConstraintException) { @@ -721,6 +756,14 @@ class TrainlogRepository( entry.events.none { it.type == SleepEventType.FINAL_GET_UP } } + private fun pendingSleepDiaryEntry(at: String): SleepDiaryEntry? { + val dates = quickSleepDates(at) ?: return null + return listSleepDiary().firstOrNull { entry -> + entry.nightStartDate == dates.first && + entry.events.none { it.type == SleepEventType.FINAL_GET_UP } + } + } + private fun quickSleepDates(at: String): Pair? = runCatching { val moment = OffsetDateTime.parse(at) @@ -731,6 +774,20 @@ class TrainlogRepository( fun quickSleepBedTime(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult { if (activeSleepDiaryEntry() != null) return SleepQuickActionResult.AlreadyActive val dates = quickSleepDates(at) ?: return SleepQuickActionResult.Error + val pending = pendingSleepDiaryEntry(at) + if (pending != null) { + return mutateSleepEntry(pending, at) { current -> + current.quickDraft( + at, + events = current.events + SleepDiaryEvent( + sleepId("sle"), + SleepEventType.BED_TIME, + at, + null, + ), + ) + } + } val result = saveSleepDiary( SleepDiaryDraft( nightStartDate = dates.first, @@ -743,6 +800,7 @@ class TrainlogRepository( treatmentAndNotes = "", events = listOf(SleepDiaryEvent(sleepId("sle"), SleepEventType.BED_TIME, at, null)), ), + quickPublicationAt = at, ) return when (result) { is SaveSleepDiaryResult.Saved -> @@ -760,7 +818,16 @@ class TrainlogRepository( ): SleepQuickActionResult { if (runCatching { OffsetDateTime.parse(at) }.isFailure) return SleepQuickActionResult.Error val current = activeSleepDiaryEntry() ?: return SleepQuickActionResult.NoActiveNight - return when (val result = saveSleepDiary(transform(current))) { + return mutateSleepEntry(current, at, transform) + } + + private fun mutateSleepEntry( + current: SleepDiaryEntry, + at: String, + transform: (SleepDiaryEntry) -> SleepDiaryDraft, + ): SleepQuickActionResult { + if (runCatching { OffsetDateTime.parse(at) }.isFailure) return SleepQuickActionResult.Error + return when (val result = saveSleepDiary(transform(current), quickPublicationAt = at)) { is SaveSleepDiaryResult.Saved -> SleepQuickActionResult.Applied( SleepQuickActionReceipt(current.entryId, current.revisionId, result.revisionId), @@ -805,26 +872,61 @@ class TrainlogRepository( fun quickSleepMedication( medicationId: String, at: String = OffsetDateTime.now().toString(), + quantity: Int = 1, ): SleepQuickActionResult { + if (quantity !in 1..99) return SleepQuickActionResult.Error val medication = listSleepMedications(includeInactive = false) .firstOrNull { it.medicationId == medicationId } ?: return SleepQuickActionResult.InvalidMedication - return mutateActiveSleep(at) { current -> - current.quickDraft( + val dates = quickSleepDates(at) ?: return SleepQuickActionResult.Error + val current = pendingSleepDiaryEntry(at) + val result = if (current == null) { + saveSleepDiary( + SleepDiaryDraft( + nightStartDate = dates.first, + nightEndDate = dates.second, + createdAt = at, + updatedAt = at, + sleepQuality = null, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "", + events = emptyList(), + intakes = listOf( + MedicationIntake( + sleepId("mdi"), medication.medicationId, medication.name, at, + medication.defaultDoseValue, medication.defaultDoseUnit, "", at, quantity, + ), + ), + ), + quickPublicationAt = at, + ).let { saved -> + when (saved) { + is SaveSleepDiaryResult.Saved -> SleepQuickActionResult.Applied( + SleepQuickActionReceipt(saved.entryId, null, saved.revisionId), + ) + SaveSleepDiaryResult.Conflict -> SleepQuickActionResult.Conflict + else -> SleepQuickActionResult.Error + } + } + } else mutateSleepEntry(current, at) { currentEntry -> + currentEntry.quickDraft( at, - intakes = current.intakes + MedicationIntake( + intakes = currentEntry.intakes + MedicationIntake( intakeId = sleepId("mdi"), medicationId = medication.medicationId, medicationName = medication.name, takenAt = at, doseValue = medication.defaultDoseValue, doseUnit = medication.defaultDoseUnit, + quantity = quantity, note = "", createdAt = at, ), ) } + return result } fun quickSleepFinalGetUp(at: String = OffsetDateTime.now().toString()): SleepQuickActionResult { @@ -876,11 +978,11 @@ class TrainlogRepository( while (c.moveToNext()) add(SleepDiaryEvent(c.getString(0), SleepEventType.entries.single { it.wireValue == c.getString(1) }, c.getString(2), if (c.isNull(3)) null else c.getString(3))) } } val intakes = db.rawQuery( - "SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at " + + "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 taken_at,intake_id", arrayOf(revisionId), ).use { c -> buildList { - while (c.moveToNext()) add(MedicationIntake(c.getString(0), c.getString(1), c.getString(2), c.getString(3), if (c.isNull(4)) null else c.getDouble(4), if (c.isNull(5)) null else c.getString(5), c.getString(6), c.getString(7))) + while (c.moveToNext()) add(MedicationIntake(c.getString(0), c.getString(1), c.getString(2), c.getString(3), if (c.isNull(4)) null else c.getDouble(4), if (c.isNull(5)) null else c.getString(5), c.getString(7), c.getString(8), c.getInt(6))) } } fun quality(v: String?) = v?.let { text -> SleepQuality.entries.single { it.wireValue == text } } return SleepDiaryEntry(entryId, owner[0], owner[1], owner[2], owner[3], revisionId, @@ -921,7 +1023,7 @@ class TrainlogRepository( events = previous.events, intakes = previous.intakes, ) - return when (val result = saveSleepDiary(compensating)) { + return when (val result = saveSleepDiary(compensating, quickPublicationAt = at)) { is SaveSleepDiaryResult.Saved -> SleepQuickActionResult.Applied( SleepQuickActionReceipt(current.entryId, current.revisionId, result.revisionId), @@ -979,7 +1081,7 @@ class TrainlogRepository( val revision = cursor.getString(5) val values = db.rawQuery("SELECT sleep_quality,wake_quality,day_form,treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=?", arrayOf(revision)).use { detail -> check(detail.moveToFirst()); arrayOf(detail.getString(0),detail.getString(1),detail.getString(2),detail.getString(3)) } val events = db.rawQuery("SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=? ORDER BY start_at,event_id", arrayOf(revision)).use { eventCursor -> buildList { while (eventCursor.moveToNext()) add(SleepDiaryEvent(eventCursor.getString(0), SleepEventType.entries.single { it.wireValue == eventCursor.getString(1) }, eventCursor.getString(2), if (eventCursor.isNull(3)) null else eventCursor.getString(3))) } } - val intakes = db.rawQuery("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY taken_at,intake_id", arrayOf(revision)).use { intakeCursor -> buildList { while (intakeCursor.moveToNext()) add(MedicationIntake(intakeCursor.getString(0), intakeCursor.getString(1), intakeCursor.getString(2), intakeCursor.getString(3), if (intakeCursor.isNull(4)) null else intakeCursor.getDouble(4), if (intakeCursor.isNull(5)) null else intakeCursor.getString(5), intakeCursor.getString(6), intakeCursor.getString(7))) } } + val intakes = db.rawQuery("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 taken_at,intake_id", arrayOf(revision)).use { intakeCursor -> buildList { while (intakeCursor.moveToNext()) add(MedicationIntake(intakeCursor.getString(0), intakeCursor.getString(1), intakeCursor.getString(2), intakeCursor.getString(3), if (intakeCursor.isNull(4)) null else intakeCursor.getDouble(4), if (intakeCursor.isNull(5)) null else intakeCursor.getString(5), intakeCursor.getString(7), intakeCursor.getString(8), intakeCursor.getInt(6))) } } fun quality(value: String?) = value?.let { text -> SleepQuality.entries.single { it.wireValue == text } } val validated = if (cursor.isNull(6)) null else cursor.getString(6) val acknowledged = if (cursor.isNull(7)) null else cursor.getString(7) @@ -1096,7 +1198,7 @@ class TrainlogRepository( ) db.execSQL( "INSERT INTO sleep_medication_intakes SELECT ?,intake_id,medication_id," + - "medication_name,taken_at,dose_value,dose_unit,note,created_at " + + "medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at " + "FROM sleep_medication_intakes WHERE revision_id=?", arrayOf(revisionId, expectedRevision), ) @@ -1130,9 +1232,11 @@ class TrainlogRepository( val db = database.readableDatabase db.rawQuery( "SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at," + - "e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN " + - "sleep_diary_publication_state p ON p.entry_id=e.entry_id AND " + - "p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id", + "e.current_revision_id,e.deleted FROM sleep_diary_entries e LEFT JOIN " + + "sleep_diary_publication_state p ON p.entry_id=e.entry_id LEFT JOIN " + + "sleep_diary_quick_publication_state q ON q.entry_id=e.entry_id WHERE " + + "p.validated_revision_id=e.current_revision_id OR " + + "q.publishable_revision_id=e.current_revision_id ORDER BY e.entry_id", null, ).use { cursor -> while (cursor.moveToNext()) { @@ -1161,14 +1265,15 @@ class TrainlogRepository( val intakes = JSONArray() db.rawQuery( "SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit," + - "note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", + "quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", arrayOf(revisionId), ).use { values -> while (values.moveToNext()) intakes.put(JSONObject() .put("intake_id", values.getString(0)).put("medication_id", values.getString(1)) .put("medication_name", values.getString(2)).put("taken_at", values.getString(3)) .put("dose_value", if (values.isNull(4)) JSONObject.NULL else values.getDouble(4)) .put("dose_unit", if (values.isNull(5)) JSONObject.NULL else values.getString(5)) - .put("note", values.getString(6)).put("created_at", values.getString(7))) } + .put("quantity", values.getInt(6)) + .put("note", values.getString(7)).put("created_at", values.getString(8))) } val ancestry = sleepRevisionAncestry( db, "SELECT parent_revision_id FROM sleep_diary_revisions " + @@ -1217,7 +1322,7 @@ class TrainlogRepository( ) } } - return JSONObject().put("format", "trainlog-sleep-diary").put("version", 1) + return JSONObject().put("format", "trainlog-sleep-diary").put("version", 2) .put("generated_at", OffsetDateTime.now().toString()).put("entries", entries) .put("medications", medications).toString() } @@ -1227,8 +1332,9 @@ class TrainlogRepository( return SleepDiaryImportResult.Rejected("invalid JSON") } if (!jsonHasUniqueObjectKeys(json) || root.optString("format") != "trainlog-sleep-diary" || - root.opt("version") != 1 || !root.has("entries") || !root.has("medications")) + root.optInt("version", 0) !in 1..2 || !root.has("entries") || !root.has("medications")) return SleepDiaryImportResult.Rejected("invalid envelope") + val wireVersion = root.getInt("version") val values = root.optJSONArray("entries") ?: return SleepDiaryImportResult.Rejected("invalid entries") if (values.length() > 3660) return SleepDiaryImportResult.Rejected("entry bound exceeded") val db = database.writableDatabase @@ -1352,7 +1458,7 @@ class TrainlogRepository( for (intakeIndex in 0 until replayIntakes.length()) { val intake = replayIntakes.getJSONObject(intakeIndex) sameEvents = sameEvents && db.rawQuery("SELECT medication_id,medication_name," + - "taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes " + + "taken_at,dose_value,dose_unit,quantity,note,created_at FROM sleep_medication_intakes " + "WHERE revision_id=? AND intake_id=?", arrayOf(revisionId, intake.getString("intake_id"))).use { current -> current.moveToFirst() && current.getString(0) == intake.getString("medication_id") && current.getString(1) == intake.getString("medication_name") && @@ -1361,8 +1467,10 @@ class TrainlogRepository( (if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")) && (if (current.isNull(4)) null else current.getString(4)) == (if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")) && - current.getString(5) == intake.getString("note") && - current.getString(6) == intake.getString("created_at") + current.getInt(5) == + (if (wireVersion == 1) 1 else intake.getInt("quantity")) && + current.getString(6) == intake.getString("note") && + current.getString(7) == intake.getString("created_at") } } if (!sameEvents) return SleepDiaryImportResult.Rejected( @@ -1419,11 +1527,19 @@ class TrainlogRepository( if (intakes.length() > 32) throw IllegalArgumentException("intake bound") for (intakeIndex in 0 until intakes.length()) { val intake = intakes.getJSONObject(intakeIndex) + val expectedKeys = mutableSetOf( + "intake_id", "medication_id", "medication_name", "taken_at", + "dose_value", "dose_unit", "note", "created_at", + ) + if (wireVersion == 2) expectedKeys += "quantity" + if (intake.keys().asSequence().toSet() != expectedKeys) + throw IllegalArgumentException("invalid medication intake fields") val dose = if (intake.isNull("dose_value")) null else intake.getDouble("dose_value") val unit = if (intake.isNull("dose_unit")) null else intake.getString("dose_unit") + val quantity = if (wireVersion == 1) 1 else intake.getInt("quantity") add(MedicationIntake(intake.getString("intake_id"), intake.getString("medication_id"), intake.getString("medication_name"), intake.getString("taken_at"), dose, unit, - intake.getString("note"), intake.getString("created_at"))) + intake.getString("note"), intake.getString("created_at"), quantity)) } }) if (!sleepDraftValid(draft)) return SleepDiaryImportResult.Rejected("invalid entry") @@ -1436,9 +1552,10 @@ class TrainlogRepository( draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) } draft.intakes.forEach { intake -> db.execSQL( - "INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)", + "INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)", arrayOf(revisionId, intake.intakeId, intake.medicationId, intake.medicationName, intake.takenAt, intake.doseValue, intake.doseUnit, + intake.quantity, intake.note, intake.createdAt), ) } markSleepDiaryImported(db, entryId, revisionId, root.getString("generated_at")) @@ -11873,7 +11990,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 31, + 32, ) { override fun onConfigure( db: SQLiteDatabase, @@ -12206,6 +12323,19 @@ private class TrainlogDatabaseHelper( createCardioGuidanceTables(db) version = 31 } + if (version < 32 && newVersion >= 32) { + /* CONTRACT: quantity is a per-intake fact. Existing immutable + * revisions hydrate to one unit; quick publication is a distinct + * exact-revision marker and never validates an entire diary day. */ + if (!tableHasColumn(db, "sleep_medication_intakes", "quantity")) { + db.execSQL( + "ALTER TABLE sleep_medication_intakes ADD COLUMN quantity INTEGER NOT NULL " + + "DEFAULT 1 CHECK(quantity BETWEEN 1 AND 99)", + ) + } + createSleepQuickPublicationTable(db) + version = 32 + } if (version != newVersion) { error( @@ -12324,13 +12454,16 @@ private class TrainlogDatabaseHelper( db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_medication_intakes( revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT, intake_id TEXT NOT NULL, medication_id TEXT NOT NULL, medication_name TEXT NOT NULL, - taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT, note TEXT, created_at TEXT NOT NULL, + taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT, + quantity INTEGER NOT NULL DEFAULT 1 CHECK(quantity BETWEEN 1 AND 99), + note TEXT, created_at TEXT NOT NULL, PRIMARY KEY(revision_id,intake_id), CHECK(intake_id GLOB 'mdi_*'), CHECK(length(trim(medication_name)) BETWEEN 1 AND 160), CHECK((dose_value IS NULL AND dose_unit IS NULL) OR (dose_value>0 AND length(trim(dose_unit)) BETWEEN 1 AND 32)))""".trimIndent()) db.execSQL("CREATE INDEX IF NOT EXISTS sleep_medications_active ON sleep_medications(deleted,updated_at)") db.execSQL("CREATE INDEX IF NOT EXISTS sleep_medication_intakes_time ON sleep_medication_intakes(taken_at,medication_id)") + createSleepQuickPublicationTable(db) db.execSQL("""CREATE TABLE IF NOT EXISTS sleep_diary_publication_state( entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT, validated_revision_id TEXT, validated_at TEXT, acknowledged_revision_id TEXT, @@ -12344,6 +12477,15 @@ private class TrainlogDatabaseHelper( PRIMARY KEY(generation_id,entry_id))""".trimIndent()) } + private fun createSleepQuickPublicationTable(db: SQLiteDatabase) { + db.execSQL( + """CREATE TABLE IF NOT EXISTS sleep_diary_quick_publication_state( + entry_id TEXT PRIMARY KEY REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT, + publishable_revision_id TEXT NOT NULL, published_at TEXT NOT NULL + )""".trimIndent(), + ) + } + private fun createCardioCalibrationTables(db: SQLiteDatabase) { db.execSQL( """CREATE TABLE IF NOT EXISTS cardio_calibrations( diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt index 8070d43..c33e785 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SleepDiaryModels.kt @@ -16,7 +16,7 @@ data class SleepMedication( data class MedicationIntake( val intakeId: String, val medicationId: String, val medicationName: String, val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String, - val createdAt: String, + val createdAt: String, val quantity: Int = 1, ) data class SleepDiaryEntry( val entryId: String, val nightStartDate: String, val nightEndDate: String, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt index 43d3582..8f026fe 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SleepDiaryScreen.kt @@ -74,6 +74,12 @@ private fun SleepNightCaptureScreen( entry.events.any { it.type == SleepEventType.BED_TIME } && entry.events.none { it.type == SleepEventType.FINAL_GET_UP } } + val pending = + entries.firstOrNull { entry -> + entry.events.none { it.type == SleepEventType.BED_TIME } && + entry.events.none { it.type == SleepEventType.FINAL_GET_UP } + } + val visibleNight = active ?: pending val medications = remember(revision) { repository.listSleepMedications(includeInactive = false) @@ -117,7 +123,7 @@ private fun SleepNightCaptureScreen( Text(stringResource(R.string.sleep_night_capture_title)) } item { - active?.let { night -> + visibleNight?.let { night -> Text( stringResource( R.string.sleep_night_in_progress, @@ -144,22 +150,43 @@ private fun SleepNightCaptureScreen( if (medications.isNotEmpty()) { item { Text(stringResource(R.string.sleep_medications)) } items(medications, key = { it.medicationId }) { medication -> - Button( - onClick = { - apply( - strings.getString(R.string.sleep_medication_recorded, medication.name), - repository.quickSleepMedication(medication.medicationId), - ) - }, - enabled = active != null, + var quantity by remember(medication.medicationId) { mutableStateOf(1) } + Row( modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val dose = - medication.defaultDoseValue?.let { doseValue -> - " · " + doseValue.toString().trimEnd('0').trimEnd('.') + - " " + medication.defaultDoseUnit.orEmpty() - }.orEmpty() - Text("💊 " + medication.name + dose) + Button(onClick = { if (quantity > 1) quantity-- }) { Text("−") } + Text("×$quantity", modifier = Modifier.padding(top = 12.dp)) + Button(onClick = { if (quantity < 99) quantity++ }) { Text("+") } + Button( + onClick = { + val selectedQuantity = quantity + val result = + repository.quickSleepMedication( + medication.medicationId, + quantity = selectedQuantity, + ) + apply( + strings.getString( + R.string.sleep_medication_recorded, + medication.name + + if (selectedQuantity > 1) " ×$selectedQuantity" else "", + ), + result, + ) + if (result is TrainlogRepository.SleepQuickActionResult.Applied) { + quantity = 1 + } + }, + modifier = Modifier.weight(1f), + ) { + val dose = + medication.defaultDoseValue?.let { doseValue -> + " · " + doseValue.toString().trimEnd('0').trimEnd('.') + + " " + medication.defaultDoseUnit.orEmpty() + }.orEmpty() + Text("💊 " + medication.name + dose) + } } } } @@ -223,7 +250,7 @@ private fun SleepNightCaptureScreen( } } } - active?.let { night -> + visibleNight?.let { night -> item { Card { Column( @@ -235,7 +262,11 @@ private fun SleepNightCaptureScreen( Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt)) } night.intakes.sortedBy { it.takenAt }.forEach { intake -> - Text("💊 " + intake.medicationName + " · " + shortTime(intake.takenAt)) + Text( + "💊 " + intake.medicationName + + (if (intake.quantity > 1) " ×${intake.quantity}" else "") + + " · " + shortTime(intake.takenAt), + ) } } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index 965576c..a931977 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(31, scalar(db, "PRAGMA user_version")) + assertEquals(32, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index ada9ba6..5d8b950 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(31, scalar(db, "PRAGMA user_version")) + assertEquals(32, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index 18a21f7..f77b6bf 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(31, scalar(db, "PRAGMA user_version")) + assertEquals(32, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index df238a3..a74ecc6 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest { reopened.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(31, scalar(db, "PRAGMA user_version")) + assertEquals(32, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt index 9fa22d8..2fca2a5 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(31, it.version) } + ).use { assertEquals(32, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt index 6e95c5f..e37cd4b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt @@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(31, it.version) } + ).use { assertEquals(32, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt index 53df764..c7848c3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(31, db.version) + assertEquals(32, db.version) db.rawQuery( "SELECT session_id,started_at,ended_at FROM session_timeline_sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt index 9fe855d..9ee3673 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt @@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(31, db.version) + assertEquals(32, db.version) db.rawQuery( "SELECT session_type,session_kind FROM sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt index f72ee21..8c0bcc1 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt @@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(31, db.version) + assertEquals(32, db.version) db.rawQuery( "SELECT COUNT(*) FROM exercises WHERE " + "exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt index 9037174..668c95a 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt @@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(31, db.version) + assertEquals(32, db.version) db.rawQuery( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" + "'cardio_guidance_runs','cardio_guidance_phases'," + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt new file mode 100644 index 0000000..989e3cc --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt @@ -0,0 +1,96 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.SleepMedication +import java.util.UUID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV32SleepQuantityMigrationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun v31IntakesHydrateToOneAndQuickPublicationStartsEmpty() { + val name = "sleep-quantity-v31-${UUID.randomUUID()}.db" + val initial = TrainlogRepository(context, name) + try { + val saved = initial.saveSleepMedication( + SleepMedication( + "", "", "2026-09-23T20:00:00+02:00", "2026-09-23T20:00:00+02:00", + "Migration medication", 75.0, "mg", "", "", true, + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + initial.quickSleepMedication(saved.entryId, "2026-09-23T21:00:00+02:00") + } finally { + initial.close() + } + val path = context.getDatabasePath(name).path + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db -> + db.execSQL("PRAGMA foreign_keys=OFF") + db.execSQL("DROP TABLE sleep_diary_quick_publication_state") + db.execSQL("ALTER TABLE sleep_medication_intakes RENAME TO sleep_medication_intakes_v32") + db.execSQL( + """CREATE TABLE sleep_medication_intakes( + revision_id TEXT NOT NULL, intake_id TEXT NOT NULL, + medication_id TEXT NOT NULL, medication_name TEXT NOT NULL, + taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT, + note TEXT, created_at TEXT NOT NULL, + PRIMARY KEY(revision_id,intake_id) + )""".trimIndent(), + ) + db.execSQL( + "INSERT INTO sleep_medication_intakes SELECT revision_id,intake_id,medication_id," + + "medication_name,taken_at,dose_value,dose_unit,note,created_at " + + "FROM sleep_medication_intakes_v32", + ) + db.execSQL("DROP TABLE sleep_medication_intakes_v32") + db.execSQL("PRAGMA user_version=31") + } + + val migrated = TrainlogRepository(context, name) + migrated.listSleepDiary() + migrated.close() + SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + assertEquals(32, scalar(db, "PRAGMA user_version")) + assertEquals( + 1, + scalar( + db, + "SELECT COUNT(*) FROM pragma_table_info('sleep_medication_intakes') " + + "WHERE name='quantity' AND \"notnull\"=1 AND dflt_value='1'", + ), + ) + assertEquals(1, scalar(db, "SELECT quantity FROM sleep_medication_intakes")) + assertEquals( + 1, + scalar( + db, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' " + + "AND name='sleep_diary_quick_publication_state'", + ), + ) + assertEquals("ok", text(db, "PRAGMA integrity_check")) + } + context.deleteDatabase(name) + } + + private fun scalar(db: SQLiteDatabase, sql: String): Int = + db.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getInt(0) + } + + private fun text(db: SQLiteDatabase, sql: String): String = + db.rawQuery(sql, null).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getString(0) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 85ee0a7..598900a 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(31, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(32, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) 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 0b3ce7d..84e6328 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 @@ -360,7 +360,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("31", scalarText(name, "PRAGMA user_version")) + assertEquals("32", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt index 06f6199..58c36de 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepQuickCaptureTest.kt @@ -19,6 +19,47 @@ import org.robolectric.annotation.Config class SleepQuickCaptureTest { private val context: Context = ApplicationProvider.getApplicationContext() + @Test + fun pendingMedicationSurvivesReopenAndBedTimeKeepsEntryIdentity() { + val name = "sleep-reopen-" + UUID.randomUUID() + var repository = TrainlogRepository(context, name) + try { + val medication = + repository.saveSleepMedication( + SleepMedication( + medicationId = "", + revisionId = "", + createdAt = "2026-09-23T20:00:00+02:00", + updatedAt = "2026-09-23T20:00:00+02:00", + name = "Test medication", + defaultDoseValue = 5.0, + defaultDoseUnit = "mg", + form = "", + note = "", + active = true, + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + val pending = + repository.quickSleepMedication( + medication.entryId, + "2026-09-23T22:20:00+02:00", + quantity = 2, + ) as TrainlogRepository.SleepQuickActionResult.Applied + + repository.close() + repository = TrainlogRepository(context, name) + + val bed = + repository.quickSleepBedTime("2026-09-23T22:30:00+02:00") + as TrainlogRepository.SleepQuickActionResult.Applied + assertEquals(pending.receipt.entryId, bed.receipt.entryId) + assertEquals(2, repository.listSleepDiary().single().intakes.single().quantity) + } finally { + repository.close() + context.deleteDatabase(name) + } + } + @Test fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() { val name = "sleep-quick-" + UUID.randomUUID() @@ -44,9 +85,26 @@ class SleepQuickCaptureTest { it.medicationId == medicationCreated.entryId } + val preBed = repository.quickSleepMedication( + medication.medicationId, + "2026-09-23T22:20:00+02:00", + quantity = 2, + ) as TrainlogRepository.SleepQuickActionResult.Applied + assertNull(repository.activeHeartRateCapture()) + val pending = repository.listSleepDiary().single() + assertTrue(pending.events.none { it.type == SleepEventType.BED_TIME }) + assertEquals(2, pending.intakes.single().quantity) + val preBedExport = JSONObject(repository.buildSleepDiaryV1Json()) + val exportedPending = preBedExport.getJSONArray("entries").getJSONObject(0) + assertEquals(preBed.receipt.entryId, exportedPending.getString("entry_id")) + assertEquals(preBed.receipt.appliedRevisionId, exportedPending.getString("revision_id")) + assertEquals(0, exportedPending.getJSONArray("events").length()) + assertEquals(1, exportedPending.getJSONArray("intakes").length()) + val bed = repository.quickSleepBedTime("2026-09-23T22:30:00+02:00") as TrainlogRepository.SleepQuickActionResult.Applied + assertEquals(preBed.receipt.entryId, bed.receipt.entryId) assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate) val measurement = @@ -86,14 +144,30 @@ class SleepQuickCaptureTest { 2, beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP }, ) - assertEquals(1, beforeGetUp.intakes.size) - assertEquals(5.0, checkNotNull(beforeGetUp.intakes.single().doseValue), 0.0) + assertEquals(2, beforeGetUp.intakes.size) + assertEquals(2, beforeGetUp.intakes.first().quantity) + assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0) + + assertTrue( + repository.recordLiveHeartRateForActiveSleep( + "CYCPLUS H2", + "2026-09-24T04:43:00+02:00", + measurement.copy(bpm = 65), + ) is TrainlogRepository.HeartRateMutationResult.Applied, + ) assertTrue( repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00") is TrainlogRepository.SleepQuickActionResult.Applied, ) assertNull(repository.activeHeartRateCapture()) + assertNull( + repository.recordLiveHeartRateForActiveSleep( + "CYCPLUS H2", + "2026-09-24T06:32:00+02:00", + measurement.copy(bpm = 70), + ), + ) val finalEntry = repository.listSleepDiary().single() assertEquals( 1, @@ -106,6 +180,9 @@ class SleepQuickCaptureTest { assertEquals("sleep", capture.getString("context_kind")) assertEquals(finalEntry.entryId, capture.getString("context_id")) assertEquals(62, capture.getJSONArray("samples").getJSONObject(0).getInt("bpm")) + assertEquals("2026-09-23T22:30:00+02:00", capture.getString("started_at")) + assertEquals("2026-09-24T06:31:00+02:00", capture.getString("ended_at")) + assertEquals(2, capture.getJSONArray("samples").length()) } finally { repository.close() context.deleteDatabase(name) @@ -148,4 +225,22 @@ class SleepQuickCaptureTest { context.deleteDatabase(name) } } + + @Test + fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() { + val name = "sleep-no-sensor-" + UUID.randomUUID() + val repository = TrainlogRepository(context, name) + try { + repository.quickSleepBedTime("2026-09-23T22:30:00+02:00") + repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00") + assertNull(repository.activeHeartRateCapture()) + assertEquals( + 0, + JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(), + ) + } finally { + repository.close() + context.deleteDatabase(name) + } + } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index 7e3d008..4b36f86 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -509,7 +509,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(31, it.getInt(0)) + assertEquals(32, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index f142006..7595c78 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(31, cursor.getInt(0)) + assertEquals(32, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(31, cursor.getInt(0)) + assertEquals(32, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/android.md b/docs/android.md index 5f313db..d3466d8 100644 --- a/docs/android.md +++ b/docs/android.md @@ -1,6 +1,6 @@ # Android application -## Sleep Diary V1 +## Sleep Diary V1 and active V2 companion Android schema v26 additively mirrors the Sleep Diary revision/event model for local-first capture. The unreleased schema-v26 contract also includes the @@ -18,6 +18,30 @@ The screen resumes the most recent entry and distinguishes draft, ready, synchronized and modified tips. “Validate day” marks the current revision for the existing global synchronization action and never makes it immutable. +Android schema v32 additively stores each immutable medication-intake +`quantity` as an integer in `1..99`, with `1` backfilled for historical rows. +It also adds a separate quick-publication marker for the exact current +revision. Quick publication makes a current fact eligible for generation; it +does not validate a day and does not change the existing validation/ACK +lifecycle. + +The quick Sleep capture derives one night identity from the existing +18:00-to-18:00 date rule. A medication action can create that pending entry +before `BED_TIME`, later medication actions reuse it, and Couché appends +`BED_TIME` to the same stable `entry_id`. Réveil and Levé remain unavailable +until bedtime. The compact quantity control defaults to one and resets after a +successful intake; the per-unit dose and unit remain independent structured +facts. Pending entries persist through restart and can synchronize without +inventing bedtime. + +Sleep-owned HR capture remains sample-driven. A pending medication alone opens +no capture. After Couché, the first fresh real measurement creates or resumes +one `context_kind=sleep` capture for that entry, with `started_at` equal to the +factual bedtime and each measurement retaining its observed timestamp, BPM and +raw RR values. Réveil neither stops nor splits it; Levé closes it at the exact +final-get-up timestamp. No sensor measurement means no fabricated capture or +sample. + ## Background USB and private Drive synchronization SyncScreen and the explicitly enabled background service invoke one diff --git a/docs/current_state.md b/docs/current_state.md index af34f99..57540fc 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -112,8 +112,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v34; synchronized guided-cardio runs, phase snapshots and instruction changes are additive in v34 | -| Android SQLite | schema v31; Android owns durable guided-cardio runs/phases/events in addition to calibration and HR capture | +| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 | +| Android SQLite | schema v32; v32 additively gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` | @@ -134,7 +134,7 @@ parallel implementation of its rules. | Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` | | Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` | | Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` | -| Sleep Diary V1 | `SLEEP_DIARY_V1=PASS`: timestamped causal capture, Android/Web editing, full-generation synchronization, exact 18:00-to-18:00 agenda projection, medication snapshots, and localized FR/EN vector PDF export | +| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. | | Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) | | Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) | | Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) | @@ -151,14 +151,15 @@ parallel implementation of its rules. | Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads | | Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 | | Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent | -| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing Sleep Diary V1 has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; the private vc46 Sommeil screen rendered correctly and was inspected without creating a night or capture | +| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. | | Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently | | Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion | | Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync | | Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference | +| Sleep tonight readiness delta | Software validated: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, and Web 191/191 plus typecheck/build. It adds no device rollout evidence and authorizes no stable release. | | 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PASS`: native 111/111; Android 304 tests (299 pass + 5 historical skips) and debug APK build; Web 183/183 + typecheck/build baseline. Private vc46 preserves the live HR capture and samples across Training → Cardio, drives measured guidance, and renders Sommeil and Calibration cardio without mutation. No stable release is authorized by this status. | | Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK | -| Current operational cursor | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: tomorrow's recording path is ready; any stable 0.1.5 tag or release remains a separate explicit decision | +| Current operational cursor | Sleep Tonight Readiness V2 is software-validated; private-device rollout and visual review remain separate authorization, and no stable 0.1.5 tag or release is authorized. | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | @@ -171,6 +172,14 @@ parallel implementation of its rules. Desktop and Android schema numbers are independent. Neither changes the frozen Trainlog JSON V1 contract. +The active Sleep V2 delta keeps the Web agenda period-owned: every returned +night has one row on the shared 18:00-to-18:00 geometry, selecting a row is +presentation-only, and the selected stable Sleep entry drives the full-width +heart-rate detail. Its rise/fall overlays are descriptive relative measured +variation only (five-minute trailing reference excluding the current 60-second +window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They +are not sleep events, sleep stages, causes, readiness, or medical inference. + Language is device-local presentation state. Android keeps it in dedicated SharedPreferences; desktop keeps it in the XDG configuration file `trainlog/presentation.conf`. A successful selection updates the UI diff --git a/docs/database.md b/docs/database.md index d191bca..fcec25c 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,19 @@ # Desktop database +## Schema v35: Sleep Diary quantity V2 + +Schema v35 additively adds `sleep_medication_intakes.quantity`, a required +integer in `1..99` with default `1`. It is the count of the separately stored +per-unit dose, not a free-text convention and not a total-dose replacement. +Thus `75 mg` with quantity `2` retains `dose_value=75`, `dose_unit=mg`, and +`quantity=2`. Existing immutable intake rows read as one unit; the migration +does not alter their revision identity, timestamps, causal ancestry, events, +or medication snapshot. + +Sleep Diary V1 remains a frozen historical input. Quantity is carried by the +separate V2 companion; desktop v35 validates the V2 bounded value while still +hydrating historical V1 input as quantity one. + ## Schema v34: synchronized Cardio guidance V1 Schema v34 additively stores completed Android-owned guided-cardio runs. `cardio_guidance_runs` keeps the stable run/session identity and exact run bounds. `cardio_guidance_phases` stores the ordered `entry_id`-owned phase, its immutable resolved BPM target, optional exact calibration provenance, exit condition, phase bounds and final instruction. `cardio_guidance_events` stores only instruction changes, not another copy of the raw heart-rate curve. diff --git a/docs/roadmap.md b/docs/roadmap.md index c821091..fb41b02 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -69,9 +69,17 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog Bluetooth synchronization transport contract. ```text -CURRENT_OPERATIONAL_CURSOR=TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS +CURRENT_OPERATIONAL_CURSOR=Sleep Tonight Readiness V2 software validation complete; private-device rollout remains a separate decision. ``` +The current development cursor is the validated Sleep Tonight readiness delta: +V1 stays frozen/readable while active capable peers require `sleep-diary-v2`. +The remaining operational work, if authorized separately, is a non-destructive +private-device rollout and visual review; it must not be represented as a +stable release or a medical/readiness claim. The Web’s relative HR rise/fall +overlays are descriptive measured-series presentation only, never inferred +sleep states or causes. + The vc45 real-device smoke found that selecting Cardio after live capture had already started on the default Training draft left the durable capture tagged as `session`, starving Cardio guidance despite fresh global BPM. Repository diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index c462eb0..9c1556a 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -3,29 +3,37 @@ ## Sleep Diary companion `trainlog-sleep-diary` version 1 is finalized in stable v0.1.4 with both the -medication catalog and the entry-owned intake snapshots. +medication catalog and the entry-owned intake snapshots. It remains frozen and +readable historical input. The active Sleep exchange requires the separate +`sleep-diary-v2` capability and `sleep-diary-v2.json` with +`format=trainlog-sleep-diary`, `version=2`. The artifact preserves catalog and diary causal parents, rejects concurrent siblings and resurrection, and treats an exact revision replay as idempotent. This completion happened before publication or freezing of the companion; `TRAINLOG_FORMAT_V1` remains unchanged and frozen. -Every field edit commits locally as a new immutable diary revision but does -not itself enter a generation. Explicit day validation marks only the guarded -current tip publishable and starts no transport. Generation capture exports -validated current tips, records each exact generation/revision relation, and -only a correlated consumed ACK advances the acknowledged marker. A correction -after ACK therefore remains local and appears as modified until it is -revalidated and acknowledged by a later generation. Imported tips are already -validated and acknowledged because their containing generation was durably -consumed. Draft and synchronized state are not inferred from wall-clock time. +Every field edit commits locally as a new immutable diary revision. Explicit +day validation marks only the guarded current tip publishable and starts no +transport. V2 also permits the current revision of a quick-captured pending +night to be marked quick-publishable: this is a distinct exact-revision marker, +not day validation, and it does not validate an incomplete night. Generation +capture exports either eligible current tip, records each exact +generation/revision relation, and only a correlated consumed ACK advances the +acknowledged marker. A correction after ACK therefore remains local and +appears as modified until it is revalidated or quick-published again and +acknowledged by a later generation. Imported tips are already validated and +acknowledged because their containing generation was durably consumed. Draft +and synchronized state are not inferred from wall-clock time. -`trainlog-sleep-diary`, version 1, is emitted by full generations in stable -v0.1.4. It is optional when consuming an older generation so a pre-domain -artifact remains replayable. It carries bounded complete entry snapshots with +`trainlog-sleep-diary`, version 2, is the active full-generation companion. +It carries bounded complete entry snapshots with stable identities, the current immutable revision and causal parent, -offset-bearing timestamps, appreciations, notes and logical deletion. Exact -revision replay is unchanged; sibling revisions and resurrection after deletion -are rejected in the generation consumption transaction. +offset-bearing timestamps, appreciations, notes, logical deletion, and an +integer `quantity` in `1..99` on every medication intake. `dose_value` and +`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes. +V1 input omits `quantity` and is normalized to one unit. Exact revision replay +is unchanged; sibling revisions and resurrection after deletion are rejected +in the generation consumption transaction. This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal ownership. diff --git a/docs/tests.md b/docs/tests.md index 73095af..80ee778 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1894,3 +1894,38 @@ The phone was locked during the final UI probe, so the visual smoke test of Sommeil, Calibration cardio and active guided-cardio presentation remains `DEVICE_VISUAL_PENDING`. No stable `v0.1.5` tag or release is authorized by this closeout. + +## Sleep tonight readiness V2 validation + +The validated development delta retains frozen Sleep Diary V1 input and tests +the active V2 companion separately. Android migration coverage advances schema +v31 to v32 and proves the additive `quantity` column defaults historical +intakes to `1`, while the separate quick-publication state does not mark a day +validated. Desktop migration coverage advances v34 to v35 with the same +bounded `1..99` quantity contract. + +Focused Android repository/service coverage proves the full factual lifecycle: +pre-bed medication creates one durable pending 18:00-to-18:00 entry; additional +intakes reuse it; Couché adds `BED_TIME` to that same stable entry; and a +quantity of two remains distinct from the per-unit dose. It also proves +pre-bed medication opens no HR capture, the first real post-bedtime CYCPLUS +sample opens/reuses one sleep capture at factual bedtime, Réveil leaves it +open, Levé closes it at factual final get-up, and a later sample cannot append. +The no-sensor path proves Couché through Levé creates neither capture nor +sample. + +Generation/import coverage proves a pending entry can synchronize without a +fabricated `BED_TIME`, the later bedtime revision advances the same identity, +quantity round-trips exactly, V1 intake input reads as quantity one, and replay +remains idempotent. The Web suite covers all period nights, row selection and +the selected full-width Sleep HR detail, shared 18:00-axis geometry, quantity +editing/presentation and PDF `×N` presentation. HR variation unit coverage +includes stable, rise, fall, insufficient-baseline, short-capture, gap and +boundary cases. + +The reported validation passes the complete Meson inventory **111/111**, both +import-contract checks **6/6**, Android `testDebugUnitTest` and `assembleDebug`, +and the complete Web inventory **191/191** with typecheck and production build. +JSON validators, changed-file formatting, and `git diff --check` also pass. +This is software evidence only: it records no device rollout and authorizes no +stable release. diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index 4a42d94..3090456 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -35,7 +35,7 @@ class SleepExchangeTest(unittest.TestCase): CREATE TABLE sleep_diary_events(revision_id TEXT,event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id)); CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER); CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER); - CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id)); + CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,quantity INTEGER NOT NULL DEFAULT 1 CHECK(quantity BETWEEN 1 AND 99),note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id)); CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT); """) @@ -58,6 +58,60 @@ class SleepExchangeTest(unittest.TestCase): exported = exchange.build(self.db) self.assertTrue(exported["entries"][0]["deleted"]) + def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self): + first = "slr_20000000-0000-4000-8000-000000000002" + legacy = self.document(entry(first)) + self.assertEqual((1, 0), exchange.apply(self.db, legacy)) + self.assertEqual(1, self.db.execute( + "SELECT quantity FROM sleep_medication_intakes" + ).fetchone()[0]) + + second = "slr_40000000-0000-4000-8000-000000000004" + evolved = self.document(entry(second, first)) + evolved["version"] = 2 + evolved["entries"][0]["intakes"][0]["quantity"] = 2 + self.assertEqual((1, 0), exchange.apply(self.db, evolved)) + self.assertEqual(2, self.db.execute( + "SELECT quantity FROM sleep_medication_intakes WHERE revision_id=?", + (second,), + ).fetchone()[0]) + + state_before_replay = { + "entry": self.db.execute( + "SELECT current_revision_id,deleted FROM sleep_diary_entries" + ).fetchall(), + "revisions": self.db.execute( + "SELECT revision_id,parent_revision_id FROM sleep_diary_revisions " + "ORDER BY revision_id" + ).fetchall(), + "intakes": self.db.execute( + "SELECT revision_id,intake_id,quantity FROM sleep_medication_intakes " + "ORDER BY revision_id,intake_id" + ).fetchall(), + } + self.assertEqual((0, 1), exchange.apply(self.db, evolved)) + self.assertEqual(state_before_replay, { + "entry": self.db.execute( + "SELECT current_revision_id,deleted FROM sleep_diary_entries" + ).fetchall(), + "revisions": self.db.execute( + "SELECT revision_id,parent_revision_id FROM sleep_diary_revisions " + "ORDER BY revision_id" + ).fetchall(), + "intakes": self.db.execute( + "SELECT revision_id,intake_id,quantity FROM sleep_medication_intakes " + "ORDER BY revision_id,intake_id" + ).fetchall(), + }) + self.assertEqual(2, self.db.execute( + "SELECT COUNT(*) FROM sleep_diary_revisions" + ).fetchone()[0]) + self.assertEqual((1, 2), self.db.execute( + "SELECT COUNT(*),MIN(quantity) FROM sleep_medication_intakes " + "WHERE revision_id=?", + (second,), + ).fetchone()) + def test_stale_ancestor_snapshot_does_not_regress_newer_tip(self): first = "slr_20000000-0000-4000-8000-000000000002" second = "slr_40000000-0000-4000-8000-000000000004" diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 829b6cf..136dd87 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,12 +66,12 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.5") - self.assertEqual(inventory["desktop_schema"], 34) - self.assertEqual(inventory["android_schema"], 31) + self.assertEqual(inventory["desktop_schema"], 35) + self.assertEqual(inventory["android_schema"], 32) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"]) - self.assertIn("sleep-diary-v1", inventory["protocols"]) + self.assertIn("sleep-diary-v2", inventory["protocols"]) self.assertIn("cardio-sessions-v1", inventory["protocols"]) self.assertIn("cardio-calibrations-v1", inventory["protocols"]) self.assertIn("cardio-guidance-v1", inventory["protocols"]) diff --git a/tools/cardio_calibration_exchange.py b/tools/cardio_calibration_exchange.py index 92099ce..3e19b21 100644 --- a/tools/cardio_calibration_exchange.py +++ b/tools/cardio_calibration_exchange.py @@ -271,8 +271,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34): - fail("desktop schema v33-v34 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35): + fail("desktop schema v33-v35 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/cardio_guidance_exchange.py b/tools/cardio_guidance_exchange.py index d8a7fbc..4773361 100644 --- a/tools/cardio_guidance_exchange.py +++ b/tools/cardio_guidance_exchange.py @@ -395,8 +395,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] != 34: - fail("desktop schema v34 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35): + fail("desktop schema v34-v35 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 3d2a76f..19af9f0 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): - raise CausalError(f"desktop schema v20-v34 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + raise CausalError(f"desktop schema v20-v35 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 3e86d7e..a65d0df 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,8 +182,8 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): - raise LifecycleError(f"desktop schema v19-v34 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + raise LifecycleError(f"desktop schema v19-v35 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 9961af6..99df6ae 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v34 requis") + raise ValueError("schema desktop v15-v35 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index 82e1002..e523eeb 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v34 requis") + raise ValueError("schema desktop v18-v35 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index caccb1a..5acf940 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 35)) + supported_versions = tuple(range(8, 36)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v34 requis") + raise ValueError("schema desktop v8 à v35 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 803e33e..9e41882 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 35)) + supported_versions = tuple(range(8, 36)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v34 requis") + raise ValueError("schema desktop v8 à v35 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index cfbbbef..f9c4a01 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): - raise ValueError("schema desktop v12-v34 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + raise ValueError("schema desktop v12-v35 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 891789d..8e48960 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,9 +41,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 35)) + supported_versions = tuple(range(11, 36)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v34 requis") + raise ValueError("schema desktop v11-v35 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index 334fcdb..109aa84 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 4f43db5..b8d1989 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in range(8, 35): + if version not in range(8, 36): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 3fec025..202cf0a 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,12 +83,12 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 35) + supported_versions = range(11, 36) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: - raise ValueError("schema desktop v11-v34 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v35 requis (v10 accepté pour export V2 explicite)") if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") diff --git a/tools/export_programs.py b/tools/export_programs.py index 6ce12ad..19e4363 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,7 +16,7 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34): + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35): raise ValueError("desktop schema v26 through v32 required") programs = [] for row in db.execute( diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index 8ab04b8..c62161f 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("desktop schema v24, v25, v26 through v32 required") deliveries = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 5030170..b0be40f 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 35) + supported_versions = range(15, 36) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v34 requis") + raise ValueError("schema desktop v15-v35 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py index cc069a3..bbf5039 100644 --- a/tools/heart_rate_exchange.py +++ b/tools/heart_rate_exchange.py @@ -236,8 +236,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34): - fail("desktop schema v30-v34 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35): + fail("desktop schema v30-v35 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index 774db49..c5b3dff 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 35) + supported_versions = range(18, 36) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v34 requis") + raise ImportFailure("schema desktop v18-v35 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index d90bac9..b1a103e 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -206,9 +206,9 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 35) + supported_versions = range(8, 36) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v34 requis") + fail("schema desktop v8 à v35 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 80481c9..12db306 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,9 +53,9 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 35) + supported_versions = range(8, 36) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v34 requis") + fail("schema desktop v8 à v35 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): fail("causal equipment protection requires the staged artifact") imported = skipped = 0 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 0c5c441..45bfa32 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,9 +56,9 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 35) + supported_versions = range(12, 36) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v34 requis") + fail("schema desktop v12-v35 requis") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() if target is None: fail(f"cible canonique absente: {canonical}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index b0b2cf5..01379bb 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 35) + supported_versions = range(11, 36) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v34 requis") + raise ImportFailure("schema desktop v11-v35 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 6e29ab2..0ba0c70 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): - fail("schema desktop v17-v34 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + fail("schema desktop v17-v35 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index ecc747d..dadc73f 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -847,9 +847,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in range(5, 35): + if version not in range(5, 36): raise ImportFailure( - f"base desktop schema v5 à v34 attendue, version trouvée: {version}" + f"base desktop schema v5 à v35 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v34") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v35") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 4f751c8..f13033c 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 35) + supported_versions = range(15, 36) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v34 requis") + fail("schema desktop v15-v35 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index a3ee020..205290e 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -119,8 +119,8 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.5", - "desktop_schema": 34, - "android_schema": 31, + "desktop_schema": 35, + "android_schema": 32, "protocols": [ "mobile-export-v3", "mobile-history-v4", @@ -135,7 +135,7 @@ def main() -> int: "ai-session-drafts-v2", "programs-v1", "program-executions-v1", - "sleep-diary-v1", + "sleep-diary-v2", "cardio-sessions-v1", "cardio-calibrations-v1", "cardio-guidance-v1", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index 9dafc5c..c699ce7 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,7 +87,7 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34): + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35): fail("desktop schema v27 through v32 required") added = 0 advanced = 0 diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py index 16a4f88..75acfa5 100644 --- a/tools/session_timeline_exchange.py +++ b/tools/session_timeline_exchange.py @@ -184,8 +184,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34): - fail("desktop schema v31-v34 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35): + fail("desktop schema v31-v35 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index a5911a4..84cafea 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Export, validate and merge the versioned Sleep Diary V1 companion.""" +"""Export V2 and validate/merge strict Sleep Diary V1 or V2 companions.""" import argparse import json @@ -12,7 +12,7 @@ from trainlog_sqlite import connect_database FORMAT = "trainlog-sleep-diary" -VERSION = 1 +VERSION = 2 MAX_BYTES = 16 * 1024 * 1024 MAX_ENTRIES = 3660 MAX_EVENTS = 64 @@ -59,8 +59,9 @@ def timestamp(value: object) -> str: def validate(root: object) -> dict: if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "entries", "medications"}: fail("invalid sleep diary envelope") - if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION: + if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] not in (1, VERSION): fail("unsupported sleep diary format") + wire_version = root["version"] timestamp(root["generated_at"]) entries = root["entries"] if not isinstance(entries, list) or len(entries) > MAX_ENTRIES: @@ -136,7 +137,7 @@ def validate(root: object) -> dict: for intake in intakes: keys = {"intake_id", "medication_id", "medication_name", "taken_at", "dose_value", "dose_unit", "note", "created_at"} - if not isinstance(intake, dict) or set(intake) != keys: + if not isinstance(intake, dict) or set(intake) != (keys if wire_version == 1 else keys | {"quantity"}): fail("invalid medication intake") if (not INTAKE_ID.fullmatch(intake["intake_id"]) or intake["intake_id"] in seen_intakes or @@ -151,6 +152,8 @@ def validate(root: object) -> dict: (type(intake["dose_value"]) not in (int, float) or intake["dose_value"] <= 0 or not isinstance(intake["dose_unit"], str) or not intake["dose_unit"].strip())): fail("invalid medication intake dose") + if wire_version == 2 and (type(intake["quantity"]) is not int or not 1 <= intake["quantity"] <= 99): + fail("invalid medication intake quantity") medications = root["medications"] if not isinstance(medications, list) or len(medications) > MAX_MEDICATIONS: fail("medication catalog bound exceeded") @@ -250,8 +253,8 @@ def build(db: sqlite3.Connection) -> dict: ] 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], - "note": row[6], "created_at": row[7]} - for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))] + "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, @@ -296,6 +299,7 @@ def revision_is_ancestor(db: sqlite3.Connection, parent_sql: str, owner_id: str, def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: validate(root) + wire_version = root["version"] applied = 0 unchanged = 0 for medication in root["medications"]: @@ -342,6 +346,9 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: ) persisted_payload = dict(persisted) incoming_payload = dict(item) + if wire_version == 1: + incoming_payload["intakes"] = [dict(intake, quantity=1) + for intake in incoming_payload["intakes"]] persisted_payload.pop("ancestry", None) incoming_payload.pop("ancestry", None) if persisted_payload != incoming_payload: @@ -391,10 +398,10 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", [(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"]) for event in item["events"]]) - db.executemany("INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)", + db.executemany("INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)", [(item["revision_id"], intake["intake_id"], intake["medication_id"], intake["medication_name"], intake["taken_at"], intake["dose_value"], - intake["dose_unit"], intake["note"], intake["created_at"]) + intake["dose_unit"], intake.get("quantity", 1), intake["note"], intake["created_at"]) for intake in item["intakes"]]) # An imported revision has already crossed the synchronization # boundary; recording both markers prevents it appearing as a draft. @@ -422,8 +429,8 @@ def main() -> None: import_command.add_argument("--database", required=True, type=Path) args = parser.parse_args() with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34): - fail("desktop schema v30-v34 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35): + fail("desktop schema v30-v35 required") if args.command == "export": args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, separators=(",", ":")), encoding="utf-8") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index a8eeae2..f12ce07 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -81,7 +81,7 @@ ARTIFACTS = ( "session-preparations-v2.json", False, "export_session_preparations.py", ()), ("programs-v1", "trainlog-programs", 1, "programs-v1.json", False, "export_programs.py", ()), - ("sleep-diary", "trainlog-sleep-diary", 1, "sleep-diary-v1.json", False, + ("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False, "sleep_diary_exchange.py", ("export",)), ) SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { @@ -219,9 +219,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24 through v34 required") + raise GenerationError("desktop schema v24 through v35 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: @@ -508,7 +508,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str, ) if cursor.rowcount != 1: raise GenerationError("program deletion changed during capture") - sleep_path = stage / "sleep-diary-v1.json" + sleep_path = stage / "sleep-diary-v2.json" if sleep_path.is_file(): captured_sleep = sleep_diary_exchange.load(sleep_path) db.executemany( diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 8c59a5f..d0c5d4b 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 34 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 35 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/include/trainlog/sleep_diary.h b/tui/include/trainlog/sleep_diary.h index e67c25c..7bf179c 100644 --- a/tui/include/trainlog/sleep_diary.h +++ b/tui/include/trainlog/sleep_diary.h @@ -44,6 +44,7 @@ typedef struct TrainlogMedicationIntake { bool has_dose; double dose_value; char dose_unit[TRAINLOG_MEDICATION_UNIT_CAPACITY]; + unsigned int quantity; char note[TRAINLOG_MEDICATION_NOTE_CAPACITY]; char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY]; } TrainlogMedicationIntake; diff --git a/tui/src/database.c b/tui/src/database.c index 349d624..aeac48f 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1350,7 +1350,8 @@ static const char *const MIGRATE_V28_TO_V29_SQL = static const char *const MIGRATE_V29_TO_V30_SQL = "BEGIN IMMEDIATE;" "CREATE TABLE IF NOT EXISTS heart_rate_captures(" - "capture_id TEXT PRIMARY KEY,context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep'))," + "capture_id TEXT PRIMARY KEY,context_kind TEXT NOT NULL CHECK(context_kind " + "IN('session','cardio','sleep'))," "context_id TEXT NOT NULL,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,sensor_name TEXT," "imported_at TEXT NOT NULL,CHECK(capture_id GLOB 'hrc_*')," "CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR " @@ -1397,9 +1398,8 @@ static const char *const MIGRATE_V30_TO_V31_SQL = * CONTRACT: v32 adds canonical local session_kind and backfills it exactly * from legacy history. INVARIANT: no legacy codec or MAX classification is * widened to cardio. */ -static const char *const MIGRATE_V31_TO_V32_SQL = - "BEGIN IMMEDIATE;" - "PRAGMA user_version=32;COMMIT;"; +static const char *const MIGRATE_V31_TO_V32_SQL = "BEGIN IMMEDIATE;" + "PRAGMA user_version=32;COMMIT;"; /* WHY: guided cardio must retain the exact measured calibration reference * independently from the raw HR samples. CONTRACT: v33 stores only completed @@ -1433,18 +1433,22 @@ static const char *const MIGRATE_V32_TO_V33_SQL = static const char *const MIGRATE_V33_TO_V34_SQL = "BEGIN IMMEDIATE;" "CREATE TABLE IF NOT EXISTS cardio_guidance_runs(" - "run_id TEXT PRIMARY KEY,session_id TEXT NOT NULL UNIQUE REFERENCES sessions(session_id) ON DELETE RESTRICT," + "run_id TEXT PRIMARY KEY,session_id TEXT NOT NULL UNIQUE REFERENCES sessions(session_id) ON " + "DELETE RESTRICT," "started_at TEXT NOT NULL,ended_at TEXT NOT NULL,imported_at TEXT NOT NULL," "CHECK(run_id GLOB 'cgr_*'),CHECK(session_id GLOB 'se_*'));" "CREATE TABLE IF NOT EXISTS cardio_guidance_phases(" "run_id TEXT NOT NULL REFERENCES cardio_guidance_runs(run_id) ON DELETE CASCADE," - "phase_id TEXT NOT NULL UNIQUE,entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0)," + "phase_id TEXT NOT NULL UNIQUE,entry_id TEXT NOT NULL,position INTEGER NOT NULL " + "CHECK(position>=0)," "kind TEXT NOT NULL CHECK(kind IN('warmup','work','recovery','cooldown'))," "target_min_bpm INTEGER,target_max_bpm INTEGER,calibration_id TEXT," "calibration_observed_peak_bpm INTEGER,minimum_percent INTEGER,maximum_percent INTEGER," - "exit_kind TEXT NOT NULL CHECK(exit_kind IN('fixed_duration','enter_target','recover_below','duration_or_recover'))," + "exit_kind TEXT NOT NULL CHECK(exit_kind " + "IN('fixed_duration','enter_target','recover_below','duration_or_recover'))," "exit_seconds INTEGER,exit_bpm INTEGER,started_at TEXT NOT NULL,ended_at TEXT NOT NULL," - "final_instruction TEXT NOT NULL CHECK(final_instruction IN('accelerate','maintain','slow_down','suspended'))," + "final_instruction TEXT NOT NULL CHECK(final_instruction " + "IN('accelerate','maintain','slow_down','suspended'))," "PRIMARY KEY(run_id,phase_id),UNIQUE(run_id,position),CHECK(phase_id GLOB 'cgp_*')," "CHECK(entry_id GLOB 'sxe_*')," "CHECK((target_min_bpm IS NULL AND target_max_bpm IS NULL) OR " @@ -1454,7 +1458,8 @@ static const char *const MIGRATE_V33_TO_V34_SQL = "(calibration_id GLOB 'cal_*' AND calibration_observed_peak_bpm BETWEEN 1 AND 65535 AND " "minimum_percent BETWEEN 1 AND 100 AND maximum_percent BETWEEN minimum_percent AND 100))," "CHECK((exit_kind='fixed_duration' AND exit_seconds>0 AND exit_bpm IS NULL) OR " - "(exit_kind='enter_target' AND exit_seconds IS NULL AND exit_bpm IS NULL AND target_min_bpm IS NOT NULL) OR " + "(exit_kind='enter_target' AND exit_seconds IS NULL AND exit_bpm IS NULL AND target_min_bpm IS " + "NOT NULL) OR " "(exit_kind='recover_below' AND exit_seconds IS NULL AND exit_bpm BETWEEN 1 AND 65535) OR " "(exit_kind='duration_or_recover' AND exit_seconds>0 AND exit_bpm BETWEEN 1 AND 65535)));" "CREATE TABLE IF NOT EXISTS cardio_guidance_events(" @@ -1464,7 +1469,8 @@ static const char *const MIGRATE_V33_TO_V34_SQL = "bpm INTEGER CHECK(bpm IS NULL OR bpm BETWEEN 1 AND 65535)," "target_min_bpm INTEGER,target_max_bpm INTEGER," "PRIMARY KEY(run_id,phase_id,sequence)," - "FOREIGN KEY(run_id,phase_id) REFERENCES cardio_guidance_phases(run_id,phase_id) ON DELETE CASCADE," + "FOREIGN KEY(run_id,phase_id) REFERENCES cardio_guidance_phases(run_id,phase_id) ON DELETE " + "CASCADE," "CHECK((target_min_bpm IS NULL AND target_max_bpm IS NULL) OR " "(target_min_bpm BETWEEN 1 AND 65535 AND target_max_bpm BETWEEN target_min_bpm AND 65535)));" "CREATE INDEX IF NOT EXISTS cardio_guidance_session ON " @@ -1508,6 +1514,41 @@ static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) { return TRAINLOG_STATUS_OK; } +static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database) { + sqlite3_stmt *statement = NULL; + bool found = false; + int result = sqlite3_prepare_v2( + database->connection, "PRAGMA table_info(sleep_medication_intakes);", -1, &statement, NULL); + if (result != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + while ((result = sqlite3_step(statement)) == SQLITE_ROW) { + const char *name = (const char *)sqlite3_column_text(statement, 1); + if (name != NULL && strcmp(name, "quantity") == 0) { + found = true; + } + } + if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (execute_sql(database, "BEGIN IMMEDIATE;") != TRAINLOG_STATUS_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (!found && + execute_sql(database, + "ALTER TABLE sleep_medication_intakes ADD COLUMN quantity INTEGER NOT NULL " + "DEFAULT 1 CHECK(quantity BETWEEN 1 AND 99);") != TRAINLOG_STATUS_OK) { + (void)execute_sql(database, "ROLLBACK;"); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (execute_sql(database, "PRAGMA user_version=35;") != TRAINLOG_STATUS_OK || + execute_sql(database, "COMMIT;") != TRAINLOG_STATUS_OK) { + (void)execute_sql(database, "ROLLBACK;"); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return TRAINLOG_STATUS_OK; +} + /* WHY: schema v29 was already opened on the private 0.1.4 review installation * before medication capture joined the same unreleased migration. CONTRACT: * this additive repair is identical to the tail of v28 -> v29 and runs only @@ -2342,7 +2383,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || - version == 31 || version == 32 || version == 33 || version == 34) { + version == 31 || version == 32 || version == 33 || version == 34 || version == 35) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2508,6 +2549,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 34) { status = execute_sql(database, MIGRATE_V33_TO_V34_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 35) { + status = ensure_v35_medication_quantity(database); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2527,7 +2571,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v34" : "migrate database to schema v34", + version == 0 ? "create schema v35" : "migrate database to schema v35", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); @@ -4046,9 +4090,10 @@ static TrainlogStatus lookup_session_row_id(TrainlogDatabase *database, static TrainlogStatus insert_session_header(TrainlogDatabase *database, const TrainlogSessionInput *session, sqlite3_int64 *output_row_id) { - static const char *const SQL = "INSERT INTO sessions(" - "session_id, started_at, ended_at, session_type, session_kind, notes" - ") VALUES(?1, ?2, ?3, ?4, ?5, ?6);"; + static const char *const SQL = + "INSERT INTO sessions(" + "session_id, started_at, ended_at, session_type, session_kind, notes" + ") VALUES(?1, ?2, ?3, ?4, ?5, ?6);"; sqlite3_stmt *statement = NULL; const char *session_kind; diff --git a/tui/src/sleep_diary.c b/tui/src/sleep_diary.c index 4780bdb..c85b861 100644 --- a/tui/src/sleep_diary.c +++ b/tui/src/sleep_diary.c @@ -133,7 +133,8 @@ bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) { !timestamp_valid(intake->taken_at, &taken) || !timestamp_valid(intake->created_at, &intake_created) || (intake->has_dose && (!(intake->dose_value > 0.0) || intake->dose_unit[0] == '\0')) || - (!intake->has_dose && (intake->dose_value != 0.0 || intake->dose_unit[0] != '\0'))) { + (!intake->has_dose && (intake->dose_value != 0.0 || intake->dose_unit[0] != '\0')) || + intake->quantity < 1U || intake->quantity > 99U) { return false; } for (other = index + 1U; other < entry->intake_count; ++other) { @@ -159,8 +160,8 @@ static TrainlogStatus insert_revision(TrainlogDatabase *database, "VALUES(?1,?2,?3,?4,?5)"; static const char intake_sql[] = "INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id," - "medication_name,taken_at,dose_value,dose_unit,note,created_at) " - "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)"; + "medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) " + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)"; sqlite3_stmt *statement = NULL; size_t index; int result = sqlite3_prepare_v2(database->connection, revision_sql, -1, &statement, NULL); @@ -266,8 +267,11 @@ static TrainlogStatus insert_revision(TrainlogDatabase *database, ? sqlite3_bind_text(statement, 7, intake->dose_unit, -1, SQLITE_TRANSIENT) : sqlite3_bind_null(statement, 7); } - BIND_INTAKE_TEXT(8, intake->note); - BIND_INTAKE_TEXT(9, intake->created_at); + if (result == SQLITE_OK) { + result = sqlite3_bind_int(statement, 8, (int)intake->quantity); + } + BIND_INTAKE_TEXT(9, intake->note); + BIND_INTAKE_TEXT(10, intake->created_at); #undef BIND_INTAKE_TEXT if (result == SQLITE_OK) { result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK @@ -427,7 +431,8 @@ static TrainlogStatus load_entry(TrainlogDatabase *database, "SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?1 " "ORDER BY start_at COLLATE BINARY,event_id COLLATE BINARY"; static const char intakes_sql[] = - "SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note," + "SELECT " + "intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note," "created_at FROM sleep_medication_intakes WHERE revision_id=?1 " "ORDER BY taken_at COLLATE BINARY,intake_id COLLATE BINARY"; sqlite3_stmt *statement = NULL; @@ -567,8 +572,9 @@ static TrainlogStatus load_entry(TrainlogDatabase *database, intake->has_dose = sqlite3_column_type(statement, 4) != SQLITE_NULL; intake->dose_value = intake->has_dose ? sqlite3_column_double(statement, 4) : 0.0; COPY_INTAKE(dose_unit, 5); - COPY_INTAKE(note, 6); - COPY_INTAKE(created_at, 7); + intake->quantity = (unsigned int)sqlite3_column_int(statement, 6); + COPY_INTAKE(note, 7); + COPY_INTAKE(created_at, 8); #undef COPY_INTAKE result = sqlite3_step(statement); } diff --git a/tui/src/web_heart_rate.c b/tui/src/web_heart_rate.c index c3d2f24..0ff93bc 100644 --- a/tui/src/web_heart_rate.c +++ b/tui/src/web_heart_rate.c @@ -71,8 +71,7 @@ static bool append_event(yyjson_mut_doc *document, const char *entry_id, const char *exercise_id) { yyjson_mut_val *event = yyjson_mut_obj(document); - if (event == NULL || - !yyjson_mut_obj_add_strcpy(document, event, "type", type) || + if (event == NULL || !yyjson_mut_obj_add_strcpy(document, event, "type", type) || !yyjson_mut_obj_add_strcpy(document, event, "at", at) || !add_nullable_string(document, event, "end_at", end_at) || !add_nullable_string(document, event, "label", label) || @@ -88,8 +87,7 @@ static bool add_samples(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *capture, const char *capture_id) { - static const char COUNT_SQL[] = - "SELECT COUNT(*) FROM heart_rate_samples WHERE capture_id=?;"; + static const char COUNT_SQL[] = "SELECT COUNT(*) FROM heart_rate_samples WHERE capture_id=?;"; static const char SAMPLE_SQL[] = "SELECT s.sequence,s.observed_at,s.bpm,r.rr_index,r.value_1024 " "FROM heart_rate_samples s LEFT JOIN heart_rate_rr_intervals r " @@ -114,8 +112,8 @@ static bool add_samples(TrainlogDatabase *database, } count = sqlite3_column_int64(count_statement, 0); if (sqlite3_step(count_statement) != SQLITE_DONE || - sqlite3_finalize(count_statement) != SQLITE_OK || - count < 0 || (uint64_t)count > HEART_RATE_SAMPLE_MAX || + sqlite3_finalize(count_statement) != SQLITE_OK || count < 0 || + (uint64_t)count > HEART_RATE_SAMPLE_MAX || !yyjson_mut_obj_add_sint(document, capture, "sample_count", count) || !prepare(database, SAMPLE_SQL, &statement) || sqlite3_bind_text(statement, 1, capture_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { @@ -188,8 +186,7 @@ static bool add_session_events(TrainlogDatabase *database, NULL, NULL, NULL) || - sqlite3_step(statement) != SQLITE_DONE || - sqlite3_finalize(statement) != SQLITE_OK) { + sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { if (statement != NULL) { (void)sqlite3_finalize(statement); } @@ -233,7 +230,7 @@ static bool add_sleep_events(TrainlogDatabase *database, "ON e.revision_id=d.current_revision_id " "WHERE d.entry_id=? AND d.deleted=0 ORDER BY e.start_at,e.event_id;"; static const char INTAKE_SQL[] = - "SELECT i.taken_at,i.medication_name " + "SELECT i.taken_at,i.medication_name,i.quantity " "FROM sleep_diary_entries d JOIN sleep_medication_intakes i " "ON i.revision_id=d.current_revision_id " "WHERE d.entry_id=? AND d.deleted=0 ORDER BY i.taken_at,i.intake_id;"; @@ -277,13 +274,21 @@ static bool add_sleep_events(TrainlogDatabase *database, return false; } while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + char label[256]; + const char *name = (const char *)sqlite3_column_text(statement, 1); + int quantity = sqlite3_column_int(statement, 2); + if (quantity > 1) { + (void)snprintf(label, sizeof(label), "%s ×%d", name, quantity); + } else { + (void)snprintf(label, sizeof(label), "%s", name); + } if (++count > TIMELINE_EVENT_MAX || !append_event(document, events, "medication", (const char *)sqlite3_column_text(statement, 0), NULL, - (const char *)sqlite3_column_text(statement, 1), + label, NULL, NULL)) { (void)sqlite3_finalize(statement); @@ -297,9 +302,8 @@ static bool add_guidance(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root, const char *session_id) { - static const char RUN_SQL[] = - "SELECT run_id,started_at,ended_at FROM cardio_guidance_runs " - "WHERE session_id=? ORDER BY ended_at DESC,run_id DESC LIMIT 1;"; + static const char RUN_SQL[] = "SELECT run_id,started_at,ended_at FROM cardio_guidance_runs " + "WHERE session_id=? ORDER BY ended_at DESC,run_id DESC LIMIT 1;"; static const char PHASE_SQL[] = "SELECT phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm," "calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent," @@ -342,21 +346,19 @@ static bool add_guidance(TrainlogDatabase *database, guidance, "started_at", (const char *)sqlite3_column_text(run_statement, 1)) || - !yyjson_mut_obj_add_strcpy(document, - guidance, - "ended_at", - (const char *)sqlite3_column_text(run_statement, 2))) { + !yyjson_mut_obj_add_strcpy( + document, guidance, "ended_at", (const char *)sqlite3_column_text(run_statement, 2))) { (void)sqlite3_finalize(run_statement); return false; } { char stable_run_id[64]; if (snprintf(stable_run_id, sizeof(stable_run_id), "%s", run_id) < 0 || - strlen(run_id) >= sizeof(stable_run_id) || - sqlite3_step(run_statement) != SQLITE_DONE || + strlen(run_id) >= sizeof(stable_run_id) || sqlite3_step(run_statement) != SQLITE_DONE || sqlite3_finalize(run_statement) != SQLITE_OK || !prepare(database, PHASE_SQL, &phase_statement) || - sqlite3_bind_text(phase_statement, 1, stable_run_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + sqlite3_bind_text(phase_statement, 1, stable_run_id, -1, SQLITE_TRANSIENT) != + SQLITE_OK) { if (phase_statement != NULL) { (void)sqlite3_finalize(phase_statement); } @@ -374,9 +376,8 @@ static bool add_guidance(TrainlogDatabase *database, size_t event_count = 0U; int event_step; - if (++phase_count > GUIDANCE_PHASE_MAX || phase == NULL || - exit_condition == NULL || instruction_events == NULL || - strlen(phase_id) >= sizeof(stable_phase_id) || + if (++phase_count > GUIDANCE_PHASE_MAX || phase == NULL || exit_condition == NULL || + instruction_events == NULL || strlen(phase_id) >= sizeof(stable_phase_id) || snprintf(stable_phase_id, sizeof(stable_phase_id), "%s", phase_id) < 0 || !yyjson_mut_obj_add_strcpy(document, phase, "phase_id", phase_id) || !yyjson_mut_obj_add_strcpy(document, @@ -401,10 +402,14 @@ static bool add_guidance(TrainlogDatabase *database, } else { target = yyjson_mut_obj(document); if (target == NULL || - !yyjson_mut_obj_add_sint( - document, target, "minimum_bpm", sqlite3_column_int64(phase_statement, 4)) || - !yyjson_mut_obj_add_sint( - document, target, "maximum_bpm", sqlite3_column_int64(phase_statement, 5)) || + !yyjson_mut_obj_add_sint(document, + target, + "minimum_bpm", + sqlite3_column_int64(phase_statement, 4)) || + !yyjson_mut_obj_add_sint(document, + target, + "maximum_bpm", + sqlite3_column_int64(phase_statement, 5)) || !add_nullable_column_string( document, target, "calibration_id", phase_statement, 6) || !add_nullable_column_int( @@ -419,27 +424,30 @@ static bool add_guidance(TrainlogDatabase *database, } } - if (!yyjson_mut_obj_add_strcpy(document, - exit_condition, - "kind", - (const char *)sqlite3_column_text(phase_statement, 10)) || + if (!yyjson_mut_obj_add_strcpy( + document, + exit_condition, + "kind", + (const char *)sqlite3_column_text(phase_statement, 10)) || !add_nullable_column_int( document, exit_condition, "seconds", phase_statement, 11) || - !add_nullable_column_int( - document, exit_condition, "bpm", phase_statement, 12) || + !add_nullable_column_int(document, exit_condition, "bpm", phase_statement, 12) || !yyjson_mut_obj_add_val(document, phase, "exit_condition", exit_condition) || - !yyjson_mut_obj_add_strcpy(document, - phase, - "started_at", - (const char *)sqlite3_column_text(phase_statement, 13)) || - !yyjson_mut_obj_add_strcpy(document, - phase, - "ended_at", - (const char *)sqlite3_column_text(phase_statement, 14)) || - !yyjson_mut_obj_add_strcpy(document, - phase, - "final_instruction", - (const char *)sqlite3_column_text(phase_statement, 15)) || + !yyjson_mut_obj_add_strcpy( + document, + phase, + "started_at", + (const char *)sqlite3_column_text(phase_statement, 13)) || + !yyjson_mut_obj_add_strcpy( + document, + phase, + "ended_at", + (const char *)sqlite3_column_text(phase_statement, 14)) || + !yyjson_mut_obj_add_strcpy( + document, + phase, + "final_instruction", + (const char *)sqlite3_column_text(phase_statement, 15)) || !prepare(database, EVENT_SQL, &event_statement) || sqlite3_bind_text(event_statement, 1, stable_run_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || @@ -457,14 +465,16 @@ static bool add_guidance(TrainlogDatabase *database, if (++event_count > GUIDANCE_EVENT_MAX || event == NULL || !yyjson_mut_obj_add_sint( document, event, "sequence", sqlite3_column_int64(event_statement, 0)) || - !yyjson_mut_obj_add_strcpy(document, - event, - "observed_at", - (const char *)sqlite3_column_text(event_statement, 1)) || - !yyjson_mut_obj_add_strcpy(document, - event, - "instruction", - (const char *)sqlite3_column_text(event_statement, 2)) || + !yyjson_mut_obj_add_strcpy( + document, + event, + "observed_at", + (const char *)sqlite3_column_text(event_statement, 1)) || + !yyjson_mut_obj_add_strcpy( + document, + event, + "instruction", + (const char *)sqlite3_column_text(event_statement, 2)) || !add_nullable_column_int(document, event, "bpm", event_statement, 3) || !add_nullable_column_int( document, event, "target_minimum_bpm", event_statement, 4) || @@ -643,18 +653,13 @@ TrainlogStatus trainlog_web_heart_rate_timeline_json(TrainlogDatabase *database, } context_kind = kind; capture = yyjson_mut_obj(document); - if (capture == NULL || - !yyjson_mut_obj_add_bool(document, root, "available", true) || + if (capture == NULL || !yyjson_mut_obj_add_bool(document, root, "available", true) || !yyjson_mut_obj_add_strcpy(document, capture, "capture_id", capture_id) || !yyjson_mut_obj_add_strcpy(document, capture, "context_kind", context_kind) || - !yyjson_mut_obj_add_strcpy(document, - capture, - "started_at", - (const char *)sqlite3_column_text(statement, 2)) || - !yyjson_mut_obj_add_strcpy(document, - capture, - "ended_at", - (const char *)sqlite3_column_text(statement, 3)) || + !yyjson_mut_obj_add_strcpy( + document, capture, "started_at", (const char *)sqlite3_column_text(statement, 2)) || + !yyjson_mut_obj_add_strcpy( + document, capture, "ended_at", (const char *)sqlite3_column_text(statement, 3)) || !add_nullable_column_string(document, capture, "sensor_name", statement, 4) || sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || !add_samples(database, document, capture, capture_id) || diff --git a/tui/src/web_sleep.c b/tui/src/web_sleep.c index 2347fcb..fcd49b6 100644 --- a/tui/src/web_sleep.c +++ b/tui/src/web_sleep.c @@ -177,6 +177,7 @@ add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDi !(intake->has_dose ? yyjson_mut_obj_add_strcpy(document, item, "dose_unit", intake->dose_unit) : yyjson_mut_obj_add_null(document, item, "dose_unit")) || + !yyjson_mut_obj_add_uint(document, item, "quantity", intake->quantity) || !yyjson_mut_obj_add_strcpy(document, item, "note", intake->note) || !yyjson_mut_obj_add_strcpy(document, item, "created_at", intake->created_at) || !yyjson_mut_arr_add_val(intakes, item)) { @@ -430,6 +431,7 @@ static bool parse_entry(const char *body, yyjson_val *value = yyjson_arr_get(intakes, index); yyjson_val *dose_value = yyjson_obj_get(value, "dose_value"); yyjson_val *dose_unit = yyjson_obj_get(value, "dose_unit"); + yyjson_val *quantity = yyjson_obj_get(value, "quantity"); TrainlogMedicationIntake *intake = &entry->intakes[index]; valid = yyjson_is_obj(value) && @@ -448,12 +450,15 @@ static bool parse_entry(const char *body, copy_string(value, "note", intake->note, sizeof(intake->note), false) && copy_string( value, "created_at", intake->created_at, sizeof(intake->created_at), false) && + yyjson_is_uint(quantity) && yyjson_get_uint(quantity) >= 1U && + yyjson_get_uint(quantity) <= 99U && ((yyjson_is_null(dose_value) && yyjson_is_null(dose_unit)) || (yyjson_is_num(dose_value) && yyjson_get_num(dose_value) > 0.0 && copy_string( value, "dose_unit", intake->dose_unit, sizeof(intake->dose_unit), false))); intake->has_dose = yyjson_is_num(dose_value); intake->dose_value = intake->has_dose ? yyjson_get_num(dose_value) : 0.0; + intake->quantity = yyjson_is_uint(quantity) ? (unsigned int)yyjson_get_uint(quantity) : 0U; if (valid && intake->intake_id[0] == '\0') { valid = trainlog_id_generate("mdi", intake->intake_id, sizeof(intake->intake_id)) == TRAINLOG_STATUS_OK; diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index 40d4b73..ad65ce5 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -53,7 +53,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 34); + CHECK(scalar(raw, "PRAGMA user_version") == 35); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," @@ -97,7 +97,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 34); + CHECK(scalar(raw, "PRAGMA user_version") == 35); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "'sleep_medication%'") == 3); diff --git a/tui/tests/test_schema_v30_migration.c b/tui/tests/test_schema_v30_migration.c index b1f6f8e..3f50f9f 100644 --- a/tui/tests/test_schema_v30_migration.c +++ b/tui/tests/test_schema_v30_migration.c @@ -10,7 +10,7 @@ #define CHECK(value) \ do { \ if (!(value)) { \ - fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ goto cleanup; \ } \ } while (0) @@ -46,13 +46,15 @@ int main(void) { "DROP TABLE heart_rate_samples;" "DROP TABLE heart_rate_captures;" "PRAGMA user_version=29;", - NULL, NULL, NULL) == SQLITE_OK); + NULL, + NULL, + NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 34); + CHECK(version == 35); trainlog_database_close(production); production = NULL; @@ -64,13 +66,20 @@ int main(void) { "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN " "('heart_rate_samples_time','heart_rate_captures_context')") == 2); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); - CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == 1); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == + 1); result = EXIT_SUCCESS; cleanup: - if (production != NULL) trainlog_database_close(production); - if (raw != NULL) (void)sqlite3_close(raw); + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } (void)unlink(path); - if (result == EXIT_SUCCESS) puts("PASS schema v29 to v30 heart-rate migration"); + if (result == EXIT_SUCCESS) { + puts("PASS schema v29 to v30 heart-rate migration"); + } return result; } diff --git a/tui/tests/test_schema_v31_migration.c b/tui/tests/test_schema_v31_migration.c index ea92aa2..5adfc5d 100644 --- a/tui/tests/test_schema_v31_migration.c +++ b/tui/tests/test_schema_v31_migration.c @@ -10,7 +10,7 @@ #define CHECK(value) \ do { \ if (!(value)) { \ - fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ goto cleanup; \ } \ } while (0) @@ -43,13 +43,15 @@ int main(void) { CHECK(sqlite3_exec(raw, "DROP TABLE session_exercise_timeline;" "PRAGMA user_version=30;", - NULL, NULL, NULL) == SQLITE_OK); + NULL, + NULL, + NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 34); + CHECK(version == 35); trainlog_database_close(production); production = NULL; @@ -64,9 +66,15 @@ int main(void) { result = EXIT_SUCCESS; cleanup: - if (production != NULL) trainlog_database_close(production); - if (raw != NULL) (void)sqlite3_close(raw); + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } (void)unlink(path); - if (result == EXIT_SUCCESS) puts("PASS schema v30 to v31 session timeline migration"); + if (result == EXIT_SUCCESS) { + puts("PASS schema v30 to v31 session timeline migration"); + } return result; } diff --git a/tui/tests/test_schema_v32_migration.c b/tui/tests/test_schema_v32_migration.c index d9fe2eb..9a6031c 100644 --- a/tui/tests/test_schema_v32_migration.c +++ b/tui/tests/test_schema_v32_migration.c @@ -8,7 +8,13 @@ #include "trainlog/database.h" -#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0) +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + goto cleanup; \ + } \ + } while (0) static int scalar(sqlite3 *database, const char *sql) { sqlite3_stmt *statement = NULL; @@ -38,13 +44,15 @@ int main(void) { CHECK(sqlite3_exec(raw, "ALTER TABLE sessions DROP COLUMN session_kind;" "PRAGMA user_version=31;", - NULL, NULL, NULL) == SQLITE_OK); + NULL, + NULL, + NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 34); + CHECK(version == 35); trainlog_database_close(production); production = NULL; @@ -52,12 +60,15 @@ int main(void) { CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('sessions') " "WHERE name='session_kind'") == 1); - CHECK(sqlite3_exec(raw, - "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)" - "VALUES('se_11111111-1111-4111-8111-111111111111'," - "'2026-09-23T09:00:00+02:00','2026-09-23T09:30:00+02:00'," - "'training','cardio');", - NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec( + raw, + "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)" + "VALUES('se_11111111-1111-4111-8111-111111111111'," + "'2026-09-23T09:00:00+02:00','2026-09-23T09:30:00+02:00'," + "'training','cardio');", + NULL, + NULL, + NULL) == SQLITE_OK); CHECK(scalar(raw, "SELECT COUNT(*) FROM sessions WHERE session_type='training' " "AND session_kind='cardio'") == 1); @@ -65,9 +76,15 @@ int main(void) { result = EXIT_SUCCESS; cleanup: - if (production != NULL) trainlog_database_close(production); - if (raw != NULL) (void)sqlite3_close(raw); + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } (void)unlink(path); - if (result == EXIT_SUCCESS) puts("PASS schema v31 to v32 cardio session kind migration"); + if (result == EXIT_SUCCESS) { + puts("PASS schema v31 to v32 cardio session kind migration"); + } return result; } diff --git a/tui/tests/test_schema_v33_migration.c b/tui/tests/test_schema_v33_migration.c index 5f20354..cb9cca9 100644 --- a/tui/tests/test_schema_v33_migration.c +++ b/tui/tests/test_schema_v33_migration.c @@ -7,7 +7,13 @@ #include "trainlog/database.h" -#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0) +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + goto cleanup; \ + } \ + } while (0) static int scalar(sqlite3 *database, const char *sql) { sqlite3_stmt *statement = NULL; @@ -38,13 +44,15 @@ int main(void) { "DROP TABLE cardio_calibration_recovery;" "DROP TABLE cardio_calibrations;" "PRAGMA user_version=32;", - NULL, NULL, NULL) == SQLITE_OK); + NULL, + NULL, + NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 34); + CHECK(version == 35); trainlog_database_close(production); production = NULL; @@ -59,9 +67,15 @@ int main(void) { result = EXIT_SUCCESS; cleanup: - if (production != NULL) trainlog_database_close(production); - if (raw != NULL) (void)sqlite3_close(raw); + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } (void)unlink(path); - if (result == EXIT_SUCCESS) puts("PASS schema v32 to v33 cardio calibration migration"); + if (result == EXIT_SUCCESS) { + puts("PASS schema v32 to v33 cardio calibration migration"); + } return result; } diff --git a/tui/tests/test_schema_v34_migration.c b/tui/tests/test_schema_v34_migration.c index ca1fba5..5ee723a 100644 --- a/tui/tests/test_schema_v34_migration.c +++ b/tui/tests/test_schema_v34_migration.c @@ -7,7 +7,13 @@ #include "trainlog/database.h" -#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0) +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + goto cleanup; \ + } \ + } while (0) static int scalar(sqlite3 *database, const char *sql) { sqlite3_stmt *statement = NULL; @@ -34,18 +40,28 @@ int main(void) { production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, - "DROP TABLE cardio_guidance_events;" - "DROP TABLE cardio_guidance_phases;" - "DROP TABLE cardio_guidance_runs;" - "PRAGMA user_version=33;", - NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec( + raw, + "PRAGMA foreign_keys=OFF;" + "CREATE TABLE sleep_medication_intakes_v34(" + "revision_id TEXT NOT NULL,intake_id TEXT NOT NULL,medication_id TEXT NOT NULL," + "medication_name TEXT NOT NULL,taken_at TEXT NOT NULL,dose_value REAL,dose_unit TEXT," + "note TEXT,created_at TEXT NOT NULL,PRIMARY KEY(revision_id,intake_id));" + "DROP TABLE sleep_medication_intakes;" + "ALTER TABLE sleep_medication_intakes_v34 RENAME TO sleep_medication_intakes;" + "DROP TABLE cardio_guidance_events;" + "DROP TABLE cardio_guidance_phases;" + "DROP TABLE cardio_guidance_runs;" + "PRAGMA user_version=33;", + NULL, + NULL, + NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL; CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 34); + CHECK(version == 35); trainlog_database_close(production); production = NULL; @@ -57,13 +73,22 @@ int main(void) { "SELECT COUNT(*) FROM sqlite_master WHERE type='index' " "AND name IN('cardio_guidance_session','cardio_guidance_event_time')") == 2); CHECK(scalar(raw, "SELECT COUNT(*) FROM cardio_guidance_runs") == 0); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM pragma_table_info('sleep_medication_intakes') " + "WHERE name='quantity' AND \"notnull\"=1 AND dflt_value='1'") == 1); CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); result = EXIT_SUCCESS; cleanup: - if (production != NULL) trainlog_database_close(production); - if (raw != NULL) (void)sqlite3_close(raw); + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } (void)unlink(path); - if (result == EXIT_SUCCESS) puts("PASS schema v33 to v34 cardio guidance migration"); + if (result == EXIT_SUCCESS) { + puts("PASS schema v33 through v35 cardio guidance and medication quantity migration"); + } return result; } diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 16d9fda..7ba9341 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v34: SQLite rc=", - strlen("migrate database to schema v34: SQLite rc=")) == 0); + "migrate database to schema v35: SQLite rc=", + strlen("migrate database to schema v35: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) { CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=35;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=36;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 35 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 36 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index e427a53..42735f7 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v34") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v35") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_sleep_diary.c b/tui/tests/test_sleep_diary.c index 641b78d..ab09ed5 100644 --- a/tui/tests/test_sleep_diary.c +++ b/tui/tests/test_sleep_diary.c @@ -135,6 +135,7 @@ int main(void) { medication.created_at); entry.intakes[0].has_dose = true; entry.intakes[0].dose_value = 5.0; + entry.intakes[0].quantity = 1U; (void)snprintf(entry.intakes[0].dose_unit, sizeof(entry.intakes[0].dose_unit), "mg"); entry.intakes[1] = entry.intakes[0]; (void)trainlog_id_generate( diff --git a/tui/tests/test_web_heart_rate.c b/tui/tests/test_web_heart_rate.c index a259a18..0d0eeca 100644 --- a/tui/tests/test_web_heart_rate.c +++ b/tui/tests/test_web_heart_rate.c @@ -12,7 +12,13 @@ #include "trainlog/web_heart_rate.h" #include "database_internal.h" -#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0) +#define CHECK(value) \ + do { \ + if (!(value)) { \ + fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \ + goto cleanup; \ + } \ + } while (0) static const char *const SESSION_ID = "se_11111111-1111-4111-8111-111111111111"; static const char *const ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222222"; @@ -31,7 +37,8 @@ static bool execute_sql(sqlite3 *database, const char *sql) { static bool seed(sqlite3 *database) { const char *const statements[] = { - "INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields)" + "INSERT INTO " + "exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields)" " VALUES('ex_33333333-3333-4333-8333-333333333333','Vélo guidé','velo guide'," "'continuous','duration',0);" "INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)" @@ -44,13 +51,16 @@ static bool seed(sqlite3 *database) { "0,0,'none',0 FROM sessions s,exercises e WHERE " "s.session_id='se_11111111-1111-4111-8111-111111111111' AND " "e.exercise_id='ex_33333333-3333-4333-8333-333333333333';" - "INSERT INTO session_exercise_timeline(session_id,entry_id,exercise_id,started_at,ended_at,imported_at)" + "INSERT INTO " + "session_exercise_timeline(session_id,entry_id,exercise_id,started_at,ended_at,imported_at)" " VALUES('se_11111111-1111-4111-8111-111111111111'," "'sxe_22222222-2222-4222-8222-222222222222'," "'ex_33333333-3333-4333-8333-333333333333','2026-09-23T10:00:10+02:00'," "'2026-09-23T10:09:50+02:00','2026-09-23T10:11:00+02:00');", - "INSERT INTO heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at)" + "INSERT INTO " + "heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name," + "imported_at)" " VALUES('hrc_44444444-4444-4444-8444-444444444444','cardio'," "'se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:00+02:00'," "'2026-09-23T10:10:00+02:00','CYCPLUS H2','2026-09-23T10:11:00+02:00');" @@ -69,7 +79,8 @@ static bool seed(sqlite3 *database) { "'se_11111111-1111-4111-8111-111111111111','sxe_22222222-2222-4222-8222-222222222222'," "'2026-09-23T10:00:00+02:00','2026-09-23T10:05:00+02:00','2026-09-23T10:10:00+02:00'," "'hrc_44444444-4444-4444-8444-444444444444',160,'2026-09-23T10:11:00+02:00');" - "INSERT INTO cardio_calibration_recovery(calibration_id,target_offset_seconds,observed_at,bpm)" + "INSERT INTO " + "cardio_calibration_recovery(calibration_id,target_offset_seconds,observed_at,bpm)" " VALUES('cal_77777777-7777-4777-8777-777777777777',60,'2026-09-23T10:06:00+02:00',140);", "INSERT INTO cardio_guidance_runs(run_id,session_id,started_at,ended_at,imported_at)" @@ -77,12 +88,14 @@ static bool seed(sqlite3 *database) { "'se_11111111-1111-4111-8111-111111111111','2026-09-23T10:00:20+02:00'," "'2026-09-23T10:03:20+02:00','2026-09-23T10:11:00+02:00');" "INSERT INTO cardio_guidance_phases(run_id,phase_id,entry_id,position,kind,target_min_bpm," - "target_max_bpm,calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent," + "target_max_bpm,calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_" + "percent," "exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction)" " VALUES('cgr_55555555-5555-4555-8555-555555555555'," "'cgp_66666666-6666-4666-8666-666666666666','sxe_22222222-2222-4222-8222-222222222222'," "0,'work',120,140,'cal_77777777-7777-4777-8777-777777777777',160,75,88," - "'fixed_duration',180,NULL,'2026-09-23T10:00:20+02:00','2026-09-23T10:03:20+02:00','maintain');", + "'fixed_duration',180,NULL,'2026-09-23T10:00:20+02:00','2026-09-23T10:03:20+02:00','" + "maintain');", "INSERT INTO cardio_guidance_events(run_id,phase_id,sequence,observed_at,instruction,bpm," "target_min_bpm,target_max_bpm) VALUES" @@ -91,8 +104,10 @@ static bool seed(sqlite3 *database) { "('cgr_55555555-5555-4555-8555-555555555555','cgp_66666666-6666-4666-8666-666666666666'," "1,'2026-09-23T10:00:40+02:00','maintain',130,120,140);", - "INSERT INTO sleep_diary_entries(entry_id,night_start_date,night_end_date,created_at,updated_at," - "current_revision_id,deleted) VALUES('sl_88888888-8888-4888-8888-888888888888','2026-09-22'," + "INSERT INTO " + "sleep_diary_entries(entry_id,night_start_date,night_end_date,created_at,updated_at," + "current_revision_id,deleted) " + "VALUES('sl_88888888-8888-4888-8888-888888888888','2026-09-22'," "'2026-09-23','2026-09-22T22:00:00+02:00','2026-09-23T06:30:00+02:00'," "'slr_99999999-9999-4999-8999-999999999999',0);" "INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at," @@ -103,22 +118,25 @@ static bool seed(sqlite3 *database) { "INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) VALUES" "('slr_99999999-9999-4999-8999-999999999999','sle_1','bed_time'," "'2026-09-22T22:30:00+02:00',NULL)," - "('slr_99999999-9999-4999-8999-999999999999','sle_2','night_get_up'," - "'2026-09-23T02:10:00+02:00',NULL)," + "('slr_99999999-9999-4999-8999-999999999999','sle_2','sleep'," + "'2026-09-23T02:10:00+02:00','2026-09-23T03:20:00+02:00')," "('slr_99999999-9999-4999-8999-999999999999','sle_3','final_get_up'," "'2026-09-23T06:20:00+02:00',NULL);" "INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id,medication_name," - "taken_at,dose_value,dose_unit,note,created_at) VALUES(" + "taken_at,dose_value,dose_unit,quantity,note,created_at) VALUES(" "'slr_99999999-9999-4999-8999-999999999999','mdi_1','med_1','Médicament test'," - "'2026-09-22T22:40:00+02:00',NULL,NULL,NULL,'2026-09-22T22:40:00+02:00');", + "'2026-09-22T22:40:00+02:00',NULL,NULL,2,NULL,'2026-09-22T22:40:00+02:00');", - "INSERT INTO heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at)" + "INSERT INTO " + "heart_rate_captures(capture_id,context_kind,context_id,started_at,ended_at,sensor_name," + "imported_at)" " VALUES('hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','sleep'," "'sl_88888888-8888-4888-8888-888888888888','2026-09-22T22:30:00+02:00'," "'2026-09-23T06:20:00+02:00','CYCPLUS H2','2026-09-23T07:00:00+02:00');" "INSERT INTO heart_rate_samples(capture_id,sequence,observed_at,bpm,exercise_entry_id," "sensor_contact_detected,energy_expended) VALUES(" - "'hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',0,'2026-09-23T02:10:00+02:00',75,NULL,NULL,NULL);", + "'hrc_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',0,'2026-09-23T02:10:00+02:00',75,NULL,NULL," + "NULL);", }; size_t index; @@ -137,11 +155,9 @@ static yyjson_doc *timeline(TrainlogDatabase *database, const char *context_id) if (json == NULL) { return NULL; } - if (trainlog_web_heart_rate_timeline_json(database, - context_id, - json, - TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, - &size) == TRAINLOG_STATUS_OK && + if (trainlog_web_heart_rate_timeline_json( + database, context_id, json, TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, &size) == + TRAINLOG_STATUS_OK && size > 0U) { document = yyjson_read(json, size, 0); } @@ -188,7 +204,8 @@ int main(void) { CHECK(yyjson_arr_size(yyjson_obj_get(phase, "events")) == 2U); calibration = yyjson_obj_get(root, "calibration"); CHECK(yyjson_is_obj(calibration)); - CHECK(strcmp(yyjson_get_str(yyjson_obj_get(calibration, "calibration_id")), CALIBRATION_ID) == 0); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(calibration, "calibration_id")), CALIBRATION_ID) == + 0); CHECK(yyjson_get_int(yyjson_obj_get(calibration, "observed_peak_bpm")) == 160); CHECK(yyjson_arr_size(yyjson_obj_get(calibration, "recovery")) == 1U); yyjson_doc_free(document); @@ -203,6 +220,17 @@ int main(void) { CHECK(yyjson_get_int(yyjson_obj_get(capture, "sample_count")) == 1); events = yyjson_obj_get(root, "events"); CHECK(yyjson_arr_size(events) == 4U); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 0U), "at")), + "2026-09-22T22:30:00+02:00") == 0); + CHECK(yyjson_is_null(yyjson_obj_get(yyjson_arr_get(events, 0U), "end_at"))); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 1U), "at")), + "2026-09-23T02:10:00+02:00") == 0); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 1U), "end_at")), + "2026-09-23T03:20:00+02:00") == 0); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 3U), "at")), + "2026-09-22T22:40:00+02:00") == 0); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(events, 3U), "label")), + "Médicament test ×2") == 0); CHECK(yyjson_is_null(yyjson_obj_get(root, "guidance"))); CHECK(yyjson_is_null(yyjson_obj_get(root, "calibration"))); yyjson_doc_free(document); @@ -219,16 +247,23 @@ int main(void) { { char buffer[64]; size_t size = 123U; - CHECK(trainlog_web_heart_rate_timeline_json( - database, "bad", buffer, sizeof(buffer), &size) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK( + trainlog_web_heart_rate_timeline_json(database, "bad", buffer, sizeof(buffer), &size) == + TRAINLOG_STATUS_INVALID_ARGUMENT); } result = EXIT_SUCCESS; cleanup: - if (document != NULL) yyjson_doc_free(document); - if (database != NULL) trainlog_database_close(database); + if (document != NULL) { + yyjson_doc_free(document); + } + if (database != NULL) { + trainlog_database_close(database); + } (void)unlink(path); - if (result == EXIT_SUCCESS) puts("PASS factual Web heart-rate timeline"); + if (result == EXIT_SUCCESS) { + puts("PASS factual Web heart-rate timeline"); + } return result; } diff --git a/web/src/api/sleepDiary.ts b/web/src/api/sleepDiary.ts index b63e6b1..d1eeed9 100644 --- a/web/src/api/sleepDiary.ts +++ b/web/src/api/sleepDiary.ts @@ -24,6 +24,7 @@ export interface MedicationIntake { taken_at: string; dose_value: number | null; dose_unit: string | null; + quantity: number; note: string; created_at: string; } @@ -157,6 +158,9 @@ function validEntry(value: unknown): value is SleepEntry { typeof intake.taken_at === "string" && (intake.dose_value === null || typeof intake.dose_value === "number") && (intake.dose_unit === null || typeof intake.dose_unit === "string") && + Number.isInteger(intake.quantity) && + Number(intake.quantity) >= 1 && + Number(intake.quantity) <= 99 && typeof intake.note === "string" && typeof intake.created_at === "string", ) diff --git a/web/src/components/HeartRateTimelinePanel.tsx b/web/src/components/HeartRateTimelinePanel.tsx index db0207b..63bbbf9 100644 --- a/web/src/components/HeartRateTimelinePanel.tsx +++ b/web/src/components/HeartRateTimelinePanel.tsx @@ -5,6 +5,7 @@ import { type HeartRateTimeline, type HeartRateTimelineEvent, } from '../api/heartRate' +import { heartRateVariations } from './heartRateVariation' const WIDTH = 1000 const HEIGHT = 280 @@ -108,7 +109,7 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { rrCount += sample.rr_1024.length } const average = total / samples.length - return { minimum, maximum, average, rrCount, display: project(samples) } + return { minimum, maximum, average, rrCount, display: project(samples), variations: heartRateVariations(samples) } }, [timeline]) if (failed) { @@ -178,6 +179,18 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { return })} + {calculated.variations.map((variation, index) => { + const variationX = x(variation.started_at) + const width = Math.max(2, x(variation.ended_at) - variationX) + const label = variation.direction === 'rise' + ? (french ? 'Hausse notable' : 'Notable rise') + : (french ? 'Baisse notable' : 'Notable fall') + return + {label + ': ' + variation.observed_bpm.toFixed(1) + ' BPM / ' + variation.reference_bpm.toFixed(1) + ' BPM'} + + })} @@ -210,6 +223,11 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) { : 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'} } +

+ {french + ? 'Rouge : hausse notable · pêche : baisse notable. Variations relatives mesurées, sans inférence sur le sommeil ni leur cause.' + : 'Red: notable rise · peach: notable fall. Relative measured variations, with no sleep-state or cause inference.'} +

{timeline.guidance &&

{french ? 'Phases guidées' : 'Guided phases'}

diff --git a/web/src/components/heartRateVariation.test.ts b/web/src/components/heartRateVariation.test.ts new file mode 100644 index 0000000..63c0b97 --- /dev/null +++ b/web/src/components/heartRateVariation.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { HeartRateSample } from "../api/heartRate"; +import { heartRateVariations } from "./heartRateVariation"; + +const series = (values: number[], gapAt = -1): HeartRateSample[] => { + let seconds = 0; + return values.map((bpm, index) => { + if (index === gapAt) seconds += 4; + const observed_at = new Date(Date.UTC(2026, 8, 23, 20, 0, seconds)).toISOString(); + seconds += 1; + return { sequence: index, observed_at, bpm, exercise_entry_id: null, + sensor_contact_detected: null, energy_expended: null, rr_1024: [] }; + }); +}; + +const withGap = (values: number[], gapAt: number, extraSeconds: number) => { + let seconds = 0; + return values.map((bpm, index) => { + if (index === gapAt) seconds += extraSeconds; + const observed_at = new Date(Date.UTC(2026, 8, 23, 20, 0, seconds)).toISOString(); + seconds += 1; + return { sequence: index, observed_at, bpm, exercise_entry_id: null, + sensor_contact_detected: null, energy_expended: null, rr_1024: [] }; + }); +}; + +describe("heartRateVariations", () => { + it("ignores stable and captures shorter than five minutes", () => { + expect(heartRateVariations(series(Array(301).fill(60)))).toEqual([]); + expect(heartRateVariations(series(Array(120).fill(60)))).toEqual([]); + }); + + it("finds deterministic rises and falls after a trailing baseline", () => { + const rise = heartRateVariations(series([...Array(301).fill(60), ...Array(61).fill(75)])); + expect(rise[0]?.direction).toBe("rise"); + const fall = heartRateVariations(series([...Array(301).fill(80), ...Array(61).fill(60)])); + expect(fall[0]?.direction).toBe("fall"); + }); + + it("does not classify across a gap greater than three seconds", () => { + expect(heartRateVariations(series([...Array(301).fill(60), ...Array(301).fill(90)], 301))).toEqual([]); + }); + + it("becomes eligible at exactly five minutes, but not one second short", () => { + expect(heartRateVariations(series([...Array(240).fill(60), ...Array(61).fill(70)]))) + .toHaveLength(1); + expect(heartRateVariations(series([...Array(239).fill(60), ...Array(61).fill(70)]))) + .toEqual([]); + }); + + it("includes exact absolute and relative thresholds and excludes immediately below", () => { + expect(heartRateVariations(series([...Array(301).fill(60), ...Array(61).fill(70)]))[0]?.direction) + .toBe("rise"); + expect(heartRateVariations(series([...Array(301).fill(60), ...Array(61).fill(69)]))) + .toEqual([]); + expect(heartRateVariations(series([...Array(301).fill(100), ...Array(61).fill(115)]))[0]?.direction) + .toBe("rise"); + expect(heartRateVariations(series([...Array(301).fill(100), ...Array(61).fill(114)]))) + .toEqual([]); + }); + + it("merges qualifying points at a three-second gap and splits after it", () => { + const merged = heartRateVariations( + withGap([...Array(301).fill(60), ...Array(70).fill(75)], 340, 2), + ); + expect(merged).toHaveLength(1); + + const split = heartRateVariations( + withGap( + [...Array(301).fill(60), ...Array(70).fill(75), ...Array(301).fill(60), ...Array(70).fill(75)], + 371, + 3, + ), + ); + expect(split).toHaveLength(2); + }); +}); diff --git a/web/src/components/heartRateVariation.ts b/web/src/components/heartRateVariation.ts new file mode 100644 index 0000000..952fa7d --- /dev/null +++ b/web/src/components/heartRateVariation.ts @@ -0,0 +1,74 @@ +import type { HeartRateSample } from "../api/heartRate"; + +export interface HeartRateVariation { + direction: "rise" | "fall"; + started_at: string; + ended_at: string; + observed_bpm: number; + reference_bpm: number; +} + +const median = (values: number[]): number => { + const ordered = [...values].sort((left, right) => left - right); + const middle = Math.floor(ordered.length / 2); + return ordered.length % 2 === 0 + ? (ordered[middle - 1] + ordered[middle]) / 2 + : ordered[middle]; +}; + +/** Describes relative measured variation only; it does not infer sleep state or cause. */ +export function heartRateVariations(samples: HeartRateSample[]): HeartRateVariation[] { + if (samples.length === 0) return []; + const ordered = [...samples].sort( + (left, right) => Date.parse(left.observed_at) - Date.parse(right.observed_at), + ); + const origin = Date.parse(ordered[0].observed_at); + const points: HeartRateVariation[] = []; + let segmentStart = 0; + for (let index = 0; index < ordered.length; index++) { + const at = Date.parse(ordered[index].observed_at); + if (index > 0 && at - Date.parse(ordered[index - 1].observed_at) > 3000) + segmentStart = index; + if (at - origin < 300_000 || at - Date.parse(ordered[segmentStart].observed_at) < 300_000) + continue; + const short = ordered + .slice(segmentStart, index + 1) + .filter((sample) => at - Date.parse(sample.observed_at) <= 60_000) + .map((sample) => sample.bpm); + const baseline = ordered + .slice(segmentStart, index + 1) + .filter((sample) => { + const age = at - Date.parse(sample.observed_at); + return age > 60_000 && age <= 360_000; + }) + .map((sample) => sample.bpm); + if (short.length === 0 || baseline.length === 0) continue; + const observed = median(short); + const reference = median(baseline); + const threshold = Math.max(10, reference * 0.15); + const direction = observed - reference >= threshold + ? "rise" + : reference - observed >= threshold + ? "fall" + : null; + if (!direction) continue; + const previous = points[points.length - 1]; + if ( + previous?.direction === direction && + at - Date.parse(previous.ended_at) <= 3000 + ) { + previous.ended_at = ordered[index].observed_at; + previous.observed_bpm = observed; + previous.reference_bpm = reference; + } else { + points.push({ + direction, + started_at: ordered[index].observed_at, + ended_at: ordered[index].observed_at, + observed_bpm: observed, + reference_bpm: reference, + }); + } + } + return points; +} diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts index a73125d..9312552 100644 --- a/web/src/report/sleepDiaryPdf.test.ts +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -90,6 +90,7 @@ const completeEntry = (index: number): SleepEntry => { taken_at: at("22:30"), dose_value: Number(dose), dose_unit: "mg", + quantity: name === "venlafaxine" && dose === 75 ? 2 : 1, note: "", created_at: at("18:00"), })), @@ -205,6 +206,12 @@ describe("sleep diary PDF", () => { expect(content).not.toContain("nuit(s)"); }); + it("prints structured medication quantity without replacing the unit dose", async () => { + const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr")); + expect(content).toContain("venlafaxine — 75"); + expect(content).toContain("mg ×2"); + }); + it("keeps the English document independently localized", async () => { const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "en")); expect(content).toContain("SLEEP QUALITY"); diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index 32e1965..c237fb4 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -185,7 +185,7 @@ function row( `${intake.taken_at.slice(11, 16)} — ${intake.medication_name}${ intake.dose_value === null ? "" - : ` — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}` + : ` — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}${intake.quantity > 1 ? ` ×${intake.quantity}` : ""}` }`, ) .join(" ; "); diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx index 5b7bb6e..a244536 100644 --- a/web/src/routes/SleepDiaryWorkspace.test.tsx +++ b/web/src/routes/SleepDiaryWorkspace.test.tsx @@ -24,6 +24,10 @@ const api = vi.hoisted(() => ({ nextId: 0, })); +const heartRateApi = vi.hoisted(() => ({ + fetchHeartRateTimeline: vi.fn(), +})); + vi.mock("../api/sleepDiary", async (importOriginal) => { const original = await importOriginal(); return { @@ -39,6 +43,11 @@ vi.mock("../api/sleepDiary", async (importOriginal) => { }; }); +vi.mock("../api/heartRate", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, fetchHeartRateTimeline: heartRateApi.fetchHeartRateTimeline }; +}); + const emptySnapshot: SleepSnapshot = { api_version: 1, entries: [], @@ -162,6 +171,7 @@ const dayAEntry = (): SleepEntry => ({ taken_at: "2026-09-20T22:30:00+02:00", dose_value: 5, dose_unit: "mg", + quantity: 1, note: "", created_at: "2026-09-20T22:30:00+02:00", }, @@ -176,6 +186,15 @@ describe("SleepDiaryWorkspace", () => { api.nextId = 0; api.fetchSleepDiary.mockResolvedValue(emptySnapshot); api.fetchSleepMedications.mockResolvedValue([]); + heartRateApi.fetchHeartRateTimeline.mockImplementation(async (contextId) => ({ + api_version: 1, + context_id: contextId, + available: false, + capture: null, + events: [], + guidance: null, + calibration: null, + })); let revision = 0; api.saveSleepEntry.mockImplementation(async (input) => ({ entry_id: input.entry_id || "sd_00000000-0000-4000-8000-000000000001", @@ -187,6 +206,40 @@ describe("SleepDiaryWorkspace", () => { vi.useRealTimers(); }); + it("renders every deduplicated period night and changes presentation selection", async () => { + const first = dayAEntry(); + const second = { + ...dayAEntry(), + entry_id: "sl_00000000-0000-4000-8000-000000000021", + night_start_date: "2026-09-18", + night_end_date: "2026-09-19", + }; + const third = { + ...dayAEntry(), + entry_id: "sl_00000000-0000-4000-8000-000000000022", + night_start_date: "2026-09-19", + night_end_date: "2026-09-20", + }; + api.fetchSleepDiary.mockResolvedValue(snapshotWith([first, third, second, third])); + render(); + + await screen.findByTestId(`sleep-agenda-row-${first.entry_id}`); + expect(screen.getAllByTestId(/^sleep-agenda-row-/)).toHaveLength(3); + const secondRow = screen.getByTestId(`sleep-agenda-row-${second.entry_id}`); + fireEvent.click(secondRow); + expect(secondRow).toHaveClass("sleep-row-selected"); + await waitFor(() => + expect(heartRateApi.fetchHeartRateTimeline).toHaveBeenLastCalledWith( + second.entry_id, + expect.any(AbortSignal), + ), + ); + expect(api.saveSleepEntry).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Traitement et remarques particulières")).toHaveValue( + "Observation du jour A", + ); + }); + it.each([ ["bed_time", "Mise au lit", "22:30", null], ["sleep", "Sommeil", "23:00", "06:30"], @@ -591,7 +644,7 @@ describe("SleepDiaryWorkspace", () => { ); }); - it("isolates an empty active night and restores the previous night without reload", async () => { + it("isolates an empty editor night while retaining the period agenda", async () => { api.fetchSleepDiary.mockResolvedValue(snapshotWith([dayAEntry()])); api.fetchSleepMedications.mockResolvedValue([medication]); render(); @@ -616,9 +669,7 @@ describe("SleepDiaryWorkspace", () => { expect(screen.getByTestId("sleep-timeline")).toHaveTextContent( "Aucun événement enregistré pour cette nuit.", ); - expect( - screen.getByText("Aucune donnée pour cette nuit."), - ).toBeInTheDocument(); + expect(screen.getByTestId("sleep-agenda-event-bed_time")).toBeInTheDocument(); expect( screen.getByLabelText("Traitement et remarques particulières"), ).toHaveValue(""); diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx index 41f64f2..ad9f481 100644 --- a/web/src/routes/SleepDiaryWorkspace.tsx +++ b/web/src/routes/SleepDiaryWorkspace.tsx @@ -168,7 +168,7 @@ const medicationLabel = (medication: SleepMedication) => const intakeLabel = (intake: MedicationIntake) => intake.dose_value === null ? intake.medication_name - : `${intake.medication_name} — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}`.trim(); + : `${intake.medication_name} — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}${intake.quantity > 1 ? ` ×${intake.quantity}` : ""}`.trim(); const countAndDuration = (count: number, seconds: number) => `${count} · ${formatDuration(seconds)}`; @@ -287,6 +287,8 @@ export function SleepDiaryWorkspace({ const [intakeTime, setIntakeTime] = useState("22:00"); const [intakeDose, setIntakeDose] = useState(""); const [intakeUnit, setIntakeUnit] = useState("mg"); + const [intakeQuantity, setIntakeQuantity] = useState(1); + const [selectedEntryId, setSelectedEntryId] = useState(null); const identity = useRef<{ entry_id: string; revision_id: string } | null>( null, ); @@ -529,6 +531,7 @@ export function SleepDiaryWorkspace({ taken_at: takenAt, dose_value: dose, dose_unit: dose === null ? null : intakeUnit, + quantity: intakeQuantity, note: "", created_at: sleepTimestamp(), }; @@ -659,7 +662,17 @@ export function SleepDiaryWorkspace({ (entry) => entry.night_start_date === activeNight, ); const activeFacts = activeEntry ? sleepEntryFacts(activeEntry) : null; - const agendaEntries = activeEntry ? [activeEntry] : []; + const agendaEntries = Array.from( + new Map(entries.map((entry) => [entry.entry_id, entry])).values(), + ).sort( + (left, right) => + left.night_start_date.localeCompare(right.night_start_date) || + left.entry_id.localeCompare(right.entry_id), + ); + const selectedEntry = + agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ?? + activeEntry ?? + agendaEntries[0]; return (
@@ -729,10 +742,11 @@ export function SleepDiaryWorkspace({ return (
- {activeEntry && } + {selectedEntry && ( +
+ +
+ )}
@@ -1175,6 +1193,25 @@ export function SleepDiaryWorkspace({ /> {item.intake.dose_unit ?? ""} +