feat(sleep): complete pre-bed capture and multi-night cardio review
This commit is contained in:
parent
c8bfb7a759
commit
f2fd610b4f
85 changed files with 1356 additions and 336 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -2,6 +2,31 @@
|
|||
|
||||
## Unreleased — 0.1.5
|
||||
|
||||
- Completed the software-validated Sleep Tonight readiness delta without a
|
||||
device-rollout or stable-release claim. Pre-bed medication now creates or
|
||||
reuses one durable pending 18:00-to-18:00 Sleep entry; Couché appends bedtime
|
||||
to that same stable identity, while Réveil/Levé remain unavailable before
|
||||
bedtime. Pending quick facts are publishable through a distinct marker and
|
||||
are not day validation.
|
||||
- Added structured per-intake medication quantity (`1..99`) in Android schema
|
||||
v32 and desktop schema v35. The active `trainlog-sleep-diary` V2 companion
|
||||
(`sleep-diary-v2.json`) carries quantity; frozen V1 remains readable and
|
||||
interprets missing quantity as one. Per-unit dose/unit remain separate from
|
||||
quantity, which is rendered in Android, Web and PDF as appropriate (for
|
||||
example `75 mg ×2`).
|
||||
- Kept sleep HR capture factual and sample-driven: pre-bed medication alone
|
||||
creates no capture; the first real post-Couché sample opens/reuses the
|
||||
entry-owned capture at bedtime; Réveil does not split it; and Levé closes it
|
||||
exactly at final get-up. A no-sensor night fabricates neither capture nor
|
||||
sample.
|
||||
- Updated the Web Sleep agenda to render every returned night in the selected
|
||||
period on one shared 18:00-to-18:00 axis. Row selection is navigation only
|
||||
and shows the selected entry’s full-width HR detail. Relative measured HR
|
||||
rises/falls use a trailing five-minute reference excluding the current
|
||||
60-second window, thresholds of `max(10 BPM, 15%)`, and ignore gaps over
|
||||
three seconds; labels remain descriptive and make no sleep-stage, medical,
|
||||
or causal inference.
|
||||
|
||||
- Added the Cardio V1 persistence/synchronization foundation. Android schema
|
||||
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
|
||||
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' " +
|
||||
|
|
|
|||
|
|
@ -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'," +
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
77
web/src/components/heartRateVariation.test.ts
Normal file
77
web/src/components/heartRateVariation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
74
web/src/components/heartRateVariation.ts
Normal file
74
web/src/components/heartRateVariation.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(" ; ");
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue