feat(sleep): complete pre-bed capture and multi-night cardio review

This commit is contained in:
fy59 2026-09-23 20:48:06 +02:00
parent c8bfb7a759
commit f2fd610b4f
85 changed files with 1356 additions and 336 deletions

View file

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

View file

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

View file

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

View file

@ -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<Any?>(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<String, String>? =
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<Any?>(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(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' " +

View file

@ -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'," +

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = []

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = []

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"]):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <rect key={'interval-' + index} className="heart-rate-event-band"
x={eventX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM} />
})}
{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 <rect key={'variation-' + index}
className={'heart-rate-variation heart-rate-variation-' + variation.direction}
x={variationX} y={TOP} width={width} height={HEIGHT - TOP - BOTTOM}>
<title>{label + ': ' + variation.observed_bpm.toFixed(1) + ' BPM / ' + variation.reference_bpm.toFixed(1) + ' BPM'}</title>
</rect>
})}
<line className="chart-axis" x1={LEFT} y1={HEIGHT - BOTTOM}
x2={WIDTH - RIGHT} y2={HEIGHT - BOTTOM} />
<polyline className="heart-rate-line" points={points} fill="none" />
@ -210,6 +223,11 @@ export function HeartRateTimelinePanel({ contextId }: { contextId: string }) {
: 'Bounded chart projection preserves minima/maxima; statistics use every measurement.'}
</figcaption>}
</figure>
<p className="heart-rate-variation-legend">
{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.'}
</p>
{timeline.guidance && <div className="heart-rate-phases">
<h4>{french ? 'Phases guidées' : 'Guided phases'}</h4>

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof import("../api/sleepDiary")>();
return {
@ -39,6 +43,11 @@ vi.mock("../api/sleepDiary", async (importOriginal) => {
};
});
vi.mock("../api/heartRate", async (importOriginal) => {
const original = await importOriginal<typeof import("../api/heartRate")>();
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(<SleepDiaryWorkspace period="30d" language="fr" />);
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(<SleepDiaryWorkspace period="30d" language="fr" />);
@ -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("");

View file

@ -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<string | null>(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 (
<div className="sleep-workspace" data-testid="sleep-workspace">
<article className="analysis-panel analysis-wide">
@ -729,10 +742,11 @@ export function SleepDiaryWorkspace({
return (
<button
type="button"
className="sleep-row"
className={`sleep-row${selectedEntry?.entry_id === entry.entry_id ? " sleep-row-selected" : ""}`}
data-testid={`sleep-agenda-row-${entry.entry_id}`}
key={entry.entry_id}
onClick={() => edit(entry)}
onClick={() => setSelectedEntryId(entry.entry_id)}
onDoubleClick={() => edit(entry)}
>
<strong>
{t.night} {entry.night_start_date}
@ -892,7 +906,11 @@ export function SleepDiaryWorkspace({
)}
</article>
{activeEntry && <HeartRateTimelinePanel contextId={activeEntry.entry_id} />}
{selectedEntry && (
<div className="analysis-wide">
<HeartRateTimelinePanel contextId={selectedEntry.entry_id} />
</div>
)}
<article className="analysis-panel analysis-wide sleep-entry-card">
<header className="sleep-entry-header">
@ -1175,6 +1193,25 @@ export function SleepDiaryWorkspace({
/>
</label>
<span>{item.intake.dose_unit ?? ""}</span>
<label>
{language === "fr" ? "Quantité" : "Quantity"}
<input
type="number"
min="1"
max="99"
value={item.intake.quantity}
onChange={(event) =>
change(
"intakes",
draft.intakes.map((value, position) =>
position === item.index
? { ...value, quantity: Number(event.target.value) }
: value,
),
)
}
/>
</label>
<button
className="quiet-action"
type="button"
@ -1245,6 +1282,17 @@ export function SleepDiaryWorkspace({
onChange={(event) => setIntakeUnit(event.target.value)}
/>
</label>
<label>
{language === "fr" ? "Quantité" : "Quantity"}
<input
data-testid="sleep-intake-quantity"
type="number"
min="1"
max="99"
value={intakeQuantity}
onChange={(event) => setIntakeQuantity(Number(event.target.value))}
/>
</label>
<button
className="primary-action"
data-testid="sleep-add-intake"

View file

@ -679,15 +679,19 @@ h2 {
max-width: 100%;
}
.sleep-agenda {
--sleep-label-column: 12rem;
--sleep-track-column: minmax(28rem, 1fr);
--sleep-facts-column: 25rem;
--sleep-column-gap: 0.6rem;
min-width: 1120px;
display: grid;
gap: 0.35rem;
}
.sleep-axis-row {
display: grid;
grid-template-columns: 12rem minmax(28rem, 1fr) 25rem;
grid-template-columns: var(--sleep-label-column) var(--sleep-track-column) var(--sleep-facts-column);
align-items: end;
gap: 0.6rem;
gap: var(--sleep-column-gap);
padding: 0 0.45rem;
min-height: 1.2rem;
color: var(--subtext-0);
@ -712,9 +716,9 @@ h2 {
}
.sleep-row {
display: grid;
grid-template-columns: 12rem minmax(28rem, 1fr) 25rem;
grid-template-columns: var(--sleep-label-column) var(--sleep-track-column) var(--sleep-facts-column);
align-items: center;
gap: 0.6rem;
gap: var(--sleep-column-gap);
min-height: 3rem;
padding: 0.3rem 0.45rem;
color: var(--text);
@ -727,6 +731,10 @@ h2 {
.sleep-row:focus-visible {
border-color: var(--lavender);
}
.sleep-row-selected {
border-color: var(--blue);
box-shadow: inset 0 0 0 1px var(--blue);
}
.sleep-row strong {
display: grid;
gap: 0.15rem;
@ -3004,6 +3012,20 @@ h2 {
.heart-rate-event-band {
fill: rgb(180 190 254 / 6%);
}
.heart-rate-variation {
opacity: 0.2;
pointer-events: auto;
}
.heart-rate-variation-rise {
fill: var(--red);
}
.heart-rate-variation-fall {
fill: var(--peach);
}
.heart-rate-variation-legend {
color: var(--subtext-0);
font-size: 0.78rem;
}
.heart-rate-marker {
stroke: var(--surface-2);
stroke-width: 0.7;