Compare commits

..

No commits in common. "1096b1393903a41c258b62f5add3b388afe284ac" and "6948e0bbd59c6ac6e204e3635a58ff02a56bbc2a" have entirely different histories.

86 changed files with 311 additions and 3025 deletions

View file

@ -2,12 +2,6 @@
## Unreleased — 0.1.4
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value
appreciation scale and user-authored notes; no medical interpretation or
heart-rate data is introduced.
Development opened after stable v0.1.3 release.
- Implemented the loopback-only Web Analyse V1 workspace and shared bounded

View file

@ -24,7 +24,7 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.4 development) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, 18:00-to-18:00 agenda and local vector PDF export. |
| Local Web (0.1.4 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse is the current implementation cursor. |
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
@ -335,7 +335,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-18:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v29 and Android SQLite schema v26;
- desktop SQLite schema v28 and Android SQLite schema v25;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..26
manifest.getInt("schema") in 17..25
) {
"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..26) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -68,7 +68,6 @@ 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),
)
private val SUPPORTED =
(ANDROID_KINDS.values +
@ -665,7 +664,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
is ProgramsImportResult.Applied,
is CausalDeleteResult.Applied,
is CausalDeleteResult.Unchanged -> null
is TrainlogRepository.SleepDiaryImportResult.Applied -> null
else -> result.toString()
}
@ -842,10 +840,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("execution-drafts") {
repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"]))
}
if ("sleep-diary" in a)
apply("sleep-diary") {
repository.applySleepDiaryV1Json(checkNotNull(a["sleep-diary"]))
}
if ("ai-proposals" in a)
apply("ai-proposals") {
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))

View file

@ -42,11 +42,6 @@ import com.labfytools.trainlog.model.SyncedProgramDetail
import com.labfytools.trainlog.model.SyncedProgramOccurrence
import com.labfytools.trainlog.model.SyncedProgramSession
import com.labfytools.trainlog.model.SyncedProgramSummary
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.ProgramSessionExecutionState
import org.json.JSONArray
import org.json.JSONObject
@ -471,300 +466,6 @@ class TrainlogRepository(
database.close()
}
sealed interface SaveSleepDiaryResult {
data class Saved(val entryId: String, val revisionId: String) : SaveSleepDiaryResult
data object Conflict : SaveSleepDiaryResult
data object Invalid : SaveSleepDiaryResult
data object DatabaseError : SaveSleepDiaryResult
}
sealed interface SleepDiaryImportResult {
data class Applied(val advanced: Int, val unchanged: Int) : SleepDiaryImportResult
data class Rejected(val reason: String) : SleepDiaryImportResult
}
private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
private fun sleepIdentity(value: String, prefix: String): Boolean =
Regex("^${prefix}_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
.matches(value)
private fun sleepDraftValid(draft: SleepDiaryDraft): Boolean = try {
val startDate = LocalDate.parse(draft.nightStartDate)
val endDate = LocalDate.parse(draft.nightEndDate)
val created = OffsetDateTime.parse(draft.createdAt).toInstant()
val updated = OffsetDateTime.parse(draft.updatedAt).toInstant()
startDate < endDate && created <= updated && draft.treatmentAndNotes.toByteArray().size <= 16384 &&
draft.events.size <= 64 && draft.events.map { it.eventId }.filter { it.isNotEmpty() }.toSet().size ==
draft.events.count { it.eventId.isNotEmpty() } && draft.events.all { event ->
val start = OffsetDateTime.parse(event.startAt).toInstant()
if (event.type in setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)) event.endAt == null
else event.endAt != null && OffsetDateTime.parse(event.endAt).toInstant() > start
}
} catch (_: RuntimeException) { false }
/** CONTRACT: one transaction appends an immutable revision and advances
* the guarded tip. Wall time never resolves a concurrent edit. */
fun saveSleepDiary(draft: SleepDiaryDraft): 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
val entryId = draft.entryId ?: sleepId("sl")
val revisionId = sleepId("slr")
val db = database.writableDatabase
db.beginTransaction()
return try {
if (creating) {
db.execSQL(
"INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,0)",
arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId),
)
} else {
val statement = db.compileStatement("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=? WHERE entry_id=? AND current_revision_id=? AND deleted=0")
statement.bindString(1,draft.nightStartDate); statement.bindString(2,draft.nightEndDate)
statement.bindString(3,draft.updatedAt); statement.bindString(4,revisionId)
statement.bindString(5,entryId); statement.bindString(6,checkNotNull(draft.expectedRevision))
if (statement.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict
}
db.execSQL(
"INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
arrayOf(revisionId,entryId,draft.expectedRevision,draft.updatedAt,draft.sleepQuality?.wireValue,
draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes),
)
draft.events.forEach { event -> db.execSQL(
"INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
arrayOf(revisionId,event.eventId.ifEmpty { sleepId("sle") },event.type.wireValue,event.startAt,event.endAt),
) }
db.setTransactionSuccessful()
SaveSleepDiaryResult.Saved(entryId,revisionId)
} catch (_: android.database.sqlite.SQLiteConstraintException) {
SaveSleepDiaryResult.Conflict
} catch (_: android.database.sqlite.SQLiteException) {
SaveSleepDiaryResult.DatabaseError
} finally { db.endTransaction() }
}
fun listSleepDiary(limit: Int = 90): List<SleepDiaryEntry> {
require(limit in 1..3660)
val db = database.readableDatabase
return db.rawQuery("SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id FROM sleep_diary_entries WHERE deleted=0 ORDER BY night_start_date DESC,entry_id LIMIT ?", arrayOf(limit.toString())).use { cursor ->
buildList {
while (cursor.moveToNext()) {
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))) } }
fun quality(value: String?) = value?.let { text -> SleepQuality.entries.single { it.wireValue == text } }
add(SleepDiaryEntry(cursor.getString(0),cursor.getString(1),cursor.getString(2),cursor.getString(3),cursor.getString(4),revision,quality(values[0]),quality(values[1]),quality(values[2]),values[3].orEmpty(),events))
}
}
}
}
fun deleteSleepDiary(entryId: String, expectedRevision: String, deletedAt: String): SaveSleepDiaryResult {
val revisionId = sleepId("slr")
val db = database.writableDatabase
db.beginTransaction()
return try {
val current = db.rawQuery(
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=? AND deleted=0",
arrayOf(entryId),
).use { if (it.moveToFirst()) it.getString(0) else null }
if (current != expectedRevision) return SaveSleepDiaryResult.Conflict
val copied = db.compileStatement(
"INSERT INTO sleep_diary_revisions " +
"SELECT ?,entry_id,revision_id,?,sleep_quality,wake_quality,day_form,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?",
)
copied.bindString(1, revisionId)
copied.bindString(2, deletedAt)
copied.bindString(3, expectedRevision)
copied.bindString(4, entryId)
if (copied.executeInsert() < 0) return SaveSleepDiaryResult.Conflict
db.execSQL(
"INSERT INTO sleep_diary_events SELECT ?,event_id,event_type,start_at,end_at " +
"FROM sleep_diary_events WHERE revision_id=?",
arrayOf(revisionId, expectedRevision),
)
val guarded = db.compileStatement(
"UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=?,deleted=1 " +
"WHERE entry_id=? AND current_revision_id=? AND deleted=0",
)
guarded.bindString(1, deletedAt)
guarded.bindString(2, revisionId)
guarded.bindString(3, entryId)
guarded.bindString(4, expectedRevision)
if (guarded.executeUpdateDelete() != 1) return SaveSleepDiaryResult.Conflict
db.setTransactionSuccessful()
SaveSleepDiaryResult.Saved(entryId, revisionId)
} catch (_: android.database.sqlite.SQLiteException) {
SaveSleepDiaryResult.DatabaseError
} finally {
db.endTransaction()
}
}
/** Complete causal envelope used only inside the full-generation transaction. */
internal fun buildSleepDiaryV1Json(): String {
val entries = JSONArray()
val db = database.readableDatabase
db.rawQuery(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " +
"FROM sleep_diary_entries ORDER BY entry_id",
null,
).use { cursor ->
while (cursor.moveToNext()) {
val revisionId = cursor.getString(5)
val revision = db.rawQuery(
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { detail -> check(detail.moveToFirst()); arrayOf(
if (detail.isNull(0)) null else detail.getString(0),
if (detail.isNull(1)) null else detail.getString(1),
if (detail.isNull(2)) null else detail.getString(2),
if (detail.isNull(3)) null else detail.getString(3),
if (detail.isNull(4)) "" else detail.getString(4),
) }
val events = JSONArray()
db.rawQuery(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events " +
"WHERE revision_id=? ORDER BY event_id",
arrayOf(revisionId),
).use { values -> while (values.moveToNext()) events.put(
JSONObject().put("event_id", values.getString(0))
.put("type", values.getString(1)).put("start_at", values.getString(2))
.put("end_at", if (values.isNull(3)) JSONObject.NULL else values.getString(3)),
) }
entries.put(JSONObject().put("entry_id", cursor.getString(0))
.put("night_start_date", cursor.getString(1)).put("night_end_date", cursor.getString(2))
.put("created_at", cursor.getString(3)).put("updated_at", cursor.getString(4))
.put("revision_id", revisionId)
.put("parent_revision_id", revision[0] ?: JSONObject.NULL)
.put("deleted", cursor.getInt(6) != 0)
.put("sleep_quality", revision[1] ?: JSONObject.NULL)
.put("wake_quality", revision[2] ?: JSONObject.NULL)
.put("day_form", revision[3] ?: JSONObject.NULL)
.put("treatment_and_notes", revision[4]).put("events", events))
}
}
return JSONObject().put("format", "trainlog-sleep-diary").put("version", 1)
.put("generated_at", OffsetDateTime.now().toString()).put("entries", entries).toString()
}
internal fun applySleepDiaryV1Json(json: String): SleepDiaryImportResult {
val root = try { JSONObject(json) } catch (_: RuntimeException) {
return SleepDiaryImportResult.Rejected("invalid JSON")
}
if (!jsonHasUniqueObjectKeys(json) || root.optString("format") != "trainlog-sleep-diary" ||
root.opt("version") != 1 || !root.has("entries"))
return SleepDiaryImportResult.Rejected("invalid envelope")
val values = root.optJSONArray("entries") ?: return SleepDiaryImportResult.Rejected("invalid entries")
if (values.length() > 3660) return SleepDiaryImportResult.Rejected("entry bound exceeded")
val db = database.writableDatabase
var advanced = 0
var unchanged = 0
return try {
for (index in 0 until values.length()) {
val item = values.getJSONObject(index)
val entryId = item.getString("entry_id")
val revisionId = item.getString("revision_id")
val parent = if (item.isNull("parent_revision_id")) null else item.getString("parent_revision_id")
if (!sleepIdentity(entryId, "sl") || !sleepIdentity(revisionId, "slr") ||
(parent != null && !sleepIdentity(parent, "slr")))
return SleepDiaryImportResult.Rejected("invalid sleep diary identity")
val local = db.rawQuery(
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
arrayOf(entryId),
).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null }
if (local?.first == revisionId) {
val same = db.rawQuery(
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
"r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r.treatment_and_notes," +
"(SELECT COUNT(*) FROM sleep_diary_events v WHERE v.revision_id=r.revision_id) " +
"FROM sleep_diary_entries e JOIN sleep_diary_revisions r " +
"ON r.revision_id=e.current_revision_id WHERE e.entry_id=?",
arrayOf(entryId),
).use { current ->
current.moveToFirst() && current.getString(0) == item.getString("night_start_date") &&
current.getString(1) == item.getString("night_end_date") &&
current.getString(2) == item.getString("created_at") &&
current.getString(3) == item.getString("updated_at") &&
(current.getInt(4) != 0) == item.getBoolean("deleted") &&
(if (current.isNull(5)) null else current.getString(5)) == parent &&
(if (current.isNull(6)) null else current.getString(6)) ==
(if (item.isNull("sleep_quality")) null else item.getString("sleep_quality")) &&
(if (current.isNull(7)) null else current.getString(7)) ==
(if (item.isNull("wake_quality")) null else item.getString("wake_quality")) &&
(if (current.isNull(8)) null else current.getString(8)) ==
(if (item.isNull("day_form")) null else item.getString("day_form")) &&
current.getString(9).orEmpty() == item.getString("treatment_and_notes") &&
current.getInt(10) == item.getJSONArray("events").length()
}
val replayEvents = item.getJSONArray("events")
var sameEvents = same
for (eventIndex in 0 until replayEvents.length()) {
val event = replayEvents.getJSONObject(eventIndex)
sameEvents = sameEvents && db.rawQuery(
"SELECT event_type,start_at,end_at FROM sleep_diary_events " +
"WHERE revision_id=? AND event_id=?",
arrayOf(revisionId, event.getString("event_id")),
).use { current ->
current.moveToFirst() && current.getString(0) == event.getString("type") &&
current.getString(1) == event.getString("start_at") &&
(if (current.isNull(2)) null else current.getString(2)) ==
(if (event.isNull("end_at")) null else event.getString("end_at"))
}
}
if (!sameEvents) return SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
)
unchanged++
continue
}
if (local != null && parent != local.first)
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
if (local == null && parent != null)
return SleepDiaryImportResult.Rejected("unknown sleep diary parent")
val deleted = item.getBoolean("deleted")
if (local?.second == 1 && !deleted)
return SleepDiaryImportResult.Rejected("sleep diary resurrection")
val draft = SleepDiaryDraft(entryId, parent, item.getString("night_start_date"),
item.getString("night_end_date"), item.getString("created_at"), item.getString("updated_at"),
if (item.isNull("sleep_quality")) null else SleepQuality.entries.single { it.wireValue == item.getString("sleep_quality") },
if (item.isNull("wake_quality")) null else SleepQuality.entries.single { it.wireValue == item.getString("wake_quality") },
if (item.isNull("day_form")) null else SleepQuality.entries.single { it.wireValue == item.getString("day_form") },
item.getString("treatment_and_notes"), buildList {
val events = item.getJSONArray("events")
if (events.length() > 64) throw IllegalArgumentException("event bound")
for (eventIndex in 0 until events.length()) {
val event = events.getJSONObject(eventIndex)
val eventId = event.getString("event_id")
if (!sleepIdentity(eventId, "sle"))
throw IllegalArgumentException("invalid sleep event identity")
add(SleepDiaryEvent(eventId,
SleepEventType.entries.single { it.wireValue == event.getString("type") },
event.getString("start_at"),
if (event.isNull("end_at")) null else event.getString("end_at")))
}
})
if (!sleepDraftValid(draft)) return SleepDiaryImportResult.Rejected("invalid entry")
if (local == null) db.execSQL("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)",
arrayOf(entryId,draft.nightStartDate,draft.nightEndDate,draft.createdAt,draft.updatedAt,revisionId,if (deleted) 1 else 0))
else db.execSQL("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
arrayOf(draft.nightStartDate,draft.nightEndDate,draft.updatedAt,revisionId,if (deleted) 1 else 0,entryId,local.first))
db.execSQL("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
arrayOf(revisionId,entryId,parent,draft.updatedAt,draft.sleepQuality?.wireValue,draft.wakeQuality?.wireValue,draft.dayForm?.wireValue,draft.treatmentAndNotes))
draft.events.forEach { event -> db.execSQL("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
arrayOf(revisionId,event.eventId,event.type.wireValue,event.startAt,event.endAt)) }
advanced++
}
SleepDiaryImportResult.Applied(advanced, unchanged)
} catch (error: RuntimeException) {
SleepDiaryImportResult.Rejected(error.message ?: "invalid sleep diary")
}
}
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." }
@ -810,7 +511,6 @@ class TrainlogRepository(
artifacts["feedback"] = buildTrainingFeedbackJson(true)
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts
}
@ -9054,7 +8754,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
26,
25,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -9104,7 +8804,6 @@ private class TrainlogDatabaseHelper(
createSessionPreparationWithdrawalTable(db)
createSyncedProgramTables(db)
createProgramExecutionProvenance(db)
createSleepDiaryTables(db)
seedEquipment(db)
}
@ -9310,14 +9009,6 @@ private class TrainlogDatabaseHelper(
createProgramExecutionProvenance(db)
version = 25
}
if (version < 26 && newVersion >= 26) {
/* WHY: the phone captures the same minute-precise diary offline.
* CONTRACT: v26 mirrors the versioned sleep-domain structure; it
* does not change completed workout or frozen V1/V3 semantics.
* INVARIANT: migration creates no observation or causal ancestry. */
createSleepDiaryTables(db)
version = 26
}
if (version != newVersion) {
error(
@ -9384,45 +9075,6 @@ private class TrainlogDatabaseHelper(
)
}
private fun createSleepDiaryTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS sleep_diary_entries(
entry_id TEXT PRIMARY KEY, night_start_date TEXT NOT NULL,
night_end_date TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, current_revision_id TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),
CHECK(entry_id GLOB 'sl_*'), CHECK(night_start_date<night_end_date)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS sleep_diary_revisions(
revision_id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
parent_revision_id TEXT, created_at TEXT NOT NULL,
sleep_quality TEXT CHECK(sleep_quality IN('TB','B','Moy','M','TM')),
wake_quality TEXT CHECK(wake_quality IN('TB','B','Moy','M','TM')),
day_form TEXT CHECK(day_form IN('TB','B','Moy','M','TM')),
treatment_and_notes TEXT,
CHECK(treatment_and_notes IS NULL OR length(CAST(treatment_and_notes AS BLOB))<=16384),
UNIQUE(entry_id,revision_id)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS sleep_diary_events(
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
event_id TEXT NOT NULL, event_type TEXT NOT NULL CHECK(event_type IN(
'bed_time','final_get_up','night_get_up','sleep','nap','long_awake',
'half_sleep','daytime_sleepiness')),
start_at TEXT NOT NULL, end_at TEXT,
CHECK((event_type IN('bed_time','final_get_up','night_get_up','daytime_sleepiness') AND end_at IS NULL)
OR (event_type IN('sleep','nap','long_awake','half_sleep') AND end_at IS NOT NULL)),
PRIMARY KEY(revision_id,event_id)
)""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON sleep_diary_entries(night_start_date,deleted)")
db.execSQL("CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at)")
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) {

View file

@ -1,21 +0,0 @@
package com.labfytools.trainlog.model
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
enum class SleepEventType(val wireValue: String) {
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
DAYTIME_SLEEPINESS("daytime_sleepiness"),
}
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
data class SleepDiaryEntry(
val entryId: String, val nightStartDate: String, val nightEndDate: String,
val createdAt: String, val updatedAt: String, val revisionId: String,
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
)
data class SleepDiaryDraft(
val entryId: String? = null, val expectedRevision: String? = null,
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
)

View file

@ -43,7 +43,6 @@ private val drawerIcons = mapOf(
AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.SLEEP to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings,
)
@ -61,7 +60,6 @@ fun AndroidAppShell(
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.SLEEP -> R.string.nav_sleep
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
@ -75,7 +73,6 @@ fun AndroidAppShell(
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.SleepDiary -> R.string.nav_sleep
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
})
val drawerState = rememberDrawerState(DrawerValue.Closed)

View file

@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
enum class AppSection {
HOME, SESSIONS, EXERCISES,
EQUIPMENT, SLEEP, SYNC, SETTINGS,
EQUIPMENT, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -35,7 +35,6 @@ sealed interface AppRoute {
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
data object SleepDiary : AppRoute { override val section = AppSection.SLEEP }
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
@ -46,7 +45,6 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.SLEEP -> AppRoute.SleepDiary
AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings
}

View file

@ -1,151 +0,0 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import java.time.LocalDate
import java.time.OffsetDateTime
@Composable
fun SleepDiaryScreen(repository: TrainlogRepository) {
var revision by remember { mutableStateOf(0) }
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
val today = LocalDate.now()
var startDate by remember(selected) { mutableStateOf(selected?.nightStartDate ?: today.minusDays(1).toString()) }
var endDate by remember(selected) { mutableStateOf(selected?.nightEndDate ?: today.toString()) }
var sleepQuality by remember(selected) { mutableStateOf(selected?.sleepQuality) }
var wakeQuality by remember(selected) { mutableStateOf(selected?.wakeQuality) }
var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) }
var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) }
var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) }
val entries = remember(revision) { repository.listSleepDiary() }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item { Text(stringResource(R.string.sleep_current_night)) }
items(entries, key = { it.entryId }) { entry ->
Card(onClick = { selected = entry }) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("${entry.nightStartDate} → ${entry.nightEndDate}")
Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}")
}
}
}
item {
Card {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.sleep_morning))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(startDate, { startDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
OutlinedTextField(endDate, { endDate = it }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
}
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it }
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it }
Text(stringResource(R.string.sleep_day))
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it }
SleepEventType.entries.forEach { type ->
Button(onClick = {
val start = OffsetDateTime.now()
events = events + SleepDiaryEvent("", type, start.toString(),
if (type in pointTypes) null else start.plusHours(1).toString())
}) { Text(eventLabel(type)) }
}
events.forEachIndexed { index, event ->
Card {
Column(Modifier.padding(8.dp)) {
Text(eventLabel(event.type))
TimeButton(event.startAt) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(startAt = value) else current
}
}
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current } }, label = { Text("ISO-8601") })
event.endAt?.let { end ->
TimeButton(end) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(endAt = value) else current
}
}
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current } }, label = { Text("ISO-8601 →") })
}
Button(onClick = { events = events.filterIndexed { item, _ -> item != index } }) { Text(stringResource(R.string.sleep_delete)) }
}
}
}
OutlinedTextField(notes, { notes = it }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
Button(onClick = {
val now = OffsetDateTime.now().toString()
val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events))
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
}) { Text(stringResource(R.string.sleep_save)) }
selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
}
}
}
}
}
@Composable
private fun TimeButton(value: String, change: (String) -> Unit) {
val context = LocalContext.current
val parsed = runCatching { OffsetDateTime.parse(value) }.getOrNull()
Button(onClick = {
val current = parsed ?: OffsetDateTime.now()
android.app.TimePickerDialog(context, { _, hour, minute ->
change(current.withHour(hour).withMinute(minute).withSecond(0).withNano(0).toString())
}, current.hour, current.minute, true).show()
}) {
Text(stringResource(R.string.sleep_choose_time) + " " + (parsed?.toLocalTime()?.toString() ?: "—"))
}
}
private val pointTypes = setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)
@Composable
private fun QualityRow(label: String, selected: SleepQuality?, change: (SleepQuality?) -> Unit) {
Column { Text(label); Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SleepQuality.entries.forEach { value -> FilterChip(selected == value, { change(if (selected == value) null else value) }, { Text(value.wireValue) }) }
} }
}
@Composable
private fun eventLabel(type: SleepEventType): String = stringResource(when (type) {
SleepEventType.BED_TIME -> R.string.sleep_bed_time
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
SleepEventType.SLEEP -> R.string.sleep_sleep
SleepEventType.NAP -> R.string.sleep_nap
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
})

View file

@ -140,7 +140,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }

View file

@ -8,26 +8,6 @@
<string name="nav_statistics">Statistics</string>
<string name="nav_sync">Synchronization</string>
<string name="nav_settings">Settings</string>
<string name="nav_sleep">Sleep</string>
<string name="sleep_current_night">Current night</string>
<string name="sleep_morning">Morning</string>
<string name="sleep_day">Day</string>
<string name="sleep_bed_time">Bedtime</string>
<string name="sleep_final_get_up">Final get-up</string>
<string name="sleep_night_get_up">Night get-up</string>
<string name="sleep_sleep">Sleep</string>
<string name="sleep_nap">Nap</string>
<string name="sleep_long_awake">Long awakening</string>
<string name="sleep_half_sleep">Half-sleep</string>
<string name="sleep_sleepiness">Sleepiness</string>
<string name="sleep_quality">Sleep quality</string>
<string name="sleep_wake_quality">Wake quality</string>
<string name="sleep_day_form">Day form</string>
<string name="sleep_notes">Treatment and notes</string>
<string name="sleep_add_event">Add event</string>
<string name="sleep_save">Save</string>
<string name="sleep_delete">Delete</string>
<string name="sleep_choose_time">Choose time</string>
<string name="route_session_editor">Current session</string>
<string name="route_session_generator">Plan a session</string>
<string name="route_ai_drafts">Drafts</string>

View file

@ -8,26 +8,6 @@
<string name="nav_statistics">Statistiques</string>
<string name="nav_sync">Synchronisation</string>
<string name="nav_settings">Paramètres</string>
<string name="nav_sleep">Sommeil</string>
<string name="sleep_current_night">Nuit actuelle</string>
<string name="sleep_morning">Matin</string>
<string name="sleep_day">Journée</string>
<string name="sleep_bed_time">Mise au lit</string>
<string name="sleep_final_get_up">Lever</string>
<string name="sleep_night_get_up">Lever nocturne</string>
<string name="sleep_sleep">Sommeil</string>
<string name="sleep_nap">Sieste</string>
<string name="sleep_long_awake">Long réveil</string>
<string name="sleep_half_sleep">Demi-sommeil</string>
<string name="sleep_sleepiness">Somnolence</string>
<string name="sleep_quality">Qualité du sommeil</string>
<string name="sleep_wake_quality">Qualité du réveil</string>
<string name="sleep_day_form">Forme de la journée</string>
<string name="sleep_notes">Traitement et remarques</string>
<string name="sleep_add_event">Ajouter un événement</string>
<string name="sleep_save">Enregistrer</string>
<string name="sleep_delete">Supprimer</string>
<string name="sleep_choose_time">Choisir l’heure</string>
<string name="route_session_editor">Séance en cours</string>
<string name="route_session_generator">Programmer une séance</string>
<string name="route_ai_drafts">Brouillons</string>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

@ -1,50 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
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 AndroidV26SleepMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v25AddsEmptySleepDomainAndReopens() {
val name = "sleep-v25-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).useForTest { assertTrueEmpty(it) }
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("DROP TABLE sleep_diary_events")
db.execSQL("DROP TABLE sleep_diary_revisions")
db.execSQL("DROP TABLE sleep_diary_entries")
db.version = 25
}
var repository = TrainlogRepository(context, name)
try {
assertTrueEmpty(repository)
repository.close()
repository = TrainlogRepository(context, name)
assertTrueEmpty(repository)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(26, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun assertTrueEmpty(repository: TrainlogRepository) = assertEquals(emptyList<Any>(), repository.listSleepDiary())
private fun TrainlogRepository.useForTest(block: (TrainlogRepository) -> Unit) {
try { block(this) } finally { close() }
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { assertEquals(26, it.version) }
.use { assertEquals(25, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -260,7 +260,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("26", scalarText(name, "PRAGMA user_version"))
assertEquals("25", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -1,49 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
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 SleepDiaryRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun createEditConflictDeleteAndReopen() {
val name = "sleep-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val initial = SleepDiaryDraft(nightStartDate = "2026-10-24", nightEndDate = "2026-10-25",
createdAt = "2026-10-24T23:30:00+02:00", updatedAt = "2026-10-24T23:30:00+02:00",
sleepQuality = SleepQuality.B, wakeQuality = null, dayForm = null,
treatmentAndNotes = "Synthetic fixture", events = listOf(
SleepDiaryEvent("", SleepEventType.SLEEP, "2026-10-24T23:30:00+02:00", "2026-10-25T06:30:00+01:00"),
SleepDiaryEvent("", SleepEventType.DAYTIME_SLEEPINESS, "2026-10-25T14:00:00+01:00", null)))
val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved
val loaded = repository.listSleepDiary().single()
assertEquals(2, loaded.events.size)
val edited = repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId, updatedAt = "2026-10-25T18:00:00+01:00",
wakeQuality = SleepQuality.MOY, dayForm = SleepQuality.TB))
assertTrue(edited is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId)) is TrainlogRepository.SaveSleepDiaryResult.Conflict)
repository.close(); repository = TrainlogRepository(context, name)
val reopened = repository.listSleepDiary().single()
assertEquals(SleepQuality.MOY, reopened.wakeQuality)
assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId,
"2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.listSleepDiary().isEmpty())
} finally { repository.close(); context.deleteDatabase(name) }
}
}

View file

@ -506,7 +506,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(26, it.getInt(0))
assertEquals(25, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(25, 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(26, cursor.getInt(0))
assertEquals(25, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -37,7 +37,7 @@ class AppNavigationStateTest {
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
assertEquals(7, AppSection.entries.size)
assertEquals(6, AppSection.entries.size)
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)

View file

@ -1,13 +1,5 @@
# Android application
## Sleep Diary V1
Android schema v26 additively mirrors the Sleep Diary revision/event model for
local-first field capture. Its compact Morning and Day sections edit one
durable night/day entry without reproducing the desktop/PDF grid. Creation,
correction and logical deletion are revision guarded and survive repository
reopen. Full-generation exchange transports the companion, never SQLite.
## Background USB and private Drive synchronization
SyncScreen and the explicitly enabled background service invoke one

View file

@ -107,8 +107,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
| Desktop SQLite | schema v28 |
| Android SQLite | schema v25 |
| 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.4` development, synchronized across Android and desktop; latest stable release: `v0.1.3` |
@ -129,7 +129,6 @@ 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 | Implemented on the 0.1.4 review branch: timestamped causal diary, Web/Android capture, full-generation companion and local vector PDF |
| 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) |

View file

@ -1,21 +1,5 @@
# Desktop database
## Schema v29: Sleep Diary V1
Schema v29 additively owns `sleep_diary_entries`, immutable
`sleep_diary_revisions`, and revision-owned `sleep_diary_events`. An entry is
one night and its associated day. Point events have an offset-bearing
`start_at`; intervals additionally have an absolute `end_at` strictly after
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells.
Mutations advance `current_revision_id` only when the caller supplies the
current parent. Logical deletion advances the revision and retains the
tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly.
The absolute timestamps are the future heart-rate correlation boundary. Sleep
Diary V1 does not store, infer, or simulate heart-rate data.
## Schema v28: terminal Program execution provenance
Schema v28 extends `program_session_executions.state` with the terminal

View file

@ -29,7 +29,7 @@ distinction between measured values and estimates.
## Current baseline
Stable v0.1.2 is the release baseline for the v0.1.3 development cycle.
Desktop schema v29, Android schema v26, Notcurses, direct `Documents/Trainlog`
Desktop schema v28, Android schema v25, Notcurses, direct `Documents/Trainlog`
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
Generator V1 is hidden pending V2. AI session-draft exchange retains its
@ -58,10 +58,6 @@ The v0.1.4 cursor proceeds from stable v0.1.3 and its validated Web, Program
execution, USB/Drive generation, ACK, and causal-deletion contracts without
reopening those completed gates.
Sleep Diary V1 is implemented on `trainlog-0.1.4-sleep-diary-v1` and remains
under grouped review before integration. Heart-rate sensor ingestion remains
future scope; absolute diary timestamps are its prepared extension boundary.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_WEB_ANALYSIS_V1
```

View file

@ -1,18 +1,5 @@
# Synchronization exchange
## Sleep Diary companion
`trainlog-sleep-diary`, version 1, is emitted by full generations on the 0.1.4
Sleep Diary branch. It is optional when consuming an older generation so a
pre-domain artifact remains replayable. 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.
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs, MTP or Drive
transport ownership.
## USB-priority and private Drive transport
Full-generation synchronization has one business engine and two byte

View file

@ -1,10 +1,5 @@
# Tests and validation
Sleep Diary coverage includes schema creation/migration, midnight and DST
offsets, revision conflict, logical deletion/reopen, companion replay and
rollback, Web periods/deep links, Android persistence, and vector PDF
construction/pagination for 7, 14, 21 and 30 synthetic days.
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and

View file

@ -1,60 +0,0 @@
#!/usr/bin/env python3
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sleep_diary_exchange as exchange
def entry(revision, parent=None, deleted=False):
return {"entry_id": "sl_10000000-0000-4000-8000-000000000001",
"night_start_date": "2026-10-24", "night_end_date": "2026-10-25",
"created_at": "2026-10-24T22:00:00+02:00", "updated_at": "2026-10-25T18:00:00+01:00",
"revision_id": revision, "parent_revision_id": parent, "deleted": deleted,
"sleep_quality": "B", "wake_quality": "Moy", "day_form": "TB",
"treatment_and_notes": "Synthetic fixture", "events": [
{"event_id": "sle_30000000-0000-4000-8000-000000000003", "type": "sleep",
"start_at": "2026-10-24T23:30:00+02:00", "end_at": "2026-10-25T06:30:00+01:00"}]}
class SleepExchangeTest(unittest.TestCase):
def setUp(self):
self.db = sqlite3.connect(":memory:")
self.db.executescript("""
CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT,parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT);
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));
""")
def document(self, item):
return {"format": exchange.FORMAT, "version": 1, "generated_at": "2026-10-25T18:00:00+01:00", "entries": [item]}
def test_roundtrip_replay_conflict_and_non_resurrection(self):
first = "slr_20000000-0000-4000-8000-000000000002"
second = "slr_40000000-0000-4000-8000-000000000004"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
self.assertEqual((0, 1), exchange.apply(self.db, self.document(entry(first))))
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(second, first, True))))
with self.assertRaisesRegex(ValueError, "resurrect"):
exchange.apply(self.db, self.document(entry("slr_50000000-0000-4000-8000-000000000005", second, False)))
exported = exchange.build(self.db)
self.assertTrue(exported["entries"][0]["deleted"])
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
first = "slr_20000000-0000-4000-8000-000000000002"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
with self.assertRaisesRegex(ValueError, "concurrent"):
exchange.apply(self.db, self.document(entry("slr_50000000-0000-4000-8000-000000000005", None)))
invalid = entry("slr_60000000-0000-4000-8000-000000000006", first)
invalid["events"][0]["end_at"] = "2026-10-24T22:30:00+02:00"
with self.assertRaisesRegex(ValueError, "positive"):
exchange.validate(self.document(invalid))
if __name__ == "__main__":
unittest.main()

View file

@ -203,8 +203,6 @@ class GenerationTest(unittest.TestCase):
self.assertEqual([operation["payload_sha256"] for operation in operations],[row[0] for row in db.execute("SELECT payload_sha256 FROM sync_causal_operations ORDER BY operation_id")])
def test_real_consumer_rolls_back_earlier_domain_write_on_late_failure(self):
# A pre-Sleep-Diary generation remains consumable after the companion
# is introduced; the late-domain failure still rolls everything back.
names=("catalog","history","execution-drafts","exercise-aliases","exercise-profile-state","equipment-definitions","equipment-associations","body-zones","feedback","causal-deletions")
directory=self.root/"incoming";directory.mkdir()
artifacts=[]

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28):
raise CausalError(f"desktop schema v20-v28 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise LifecycleError(f"desktop schema v19-v28 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
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-v29 requis")
raise ValueError("schema desktop v15-v28 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v29 requis")
raise ValueError("schema desktop v18-v28 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 30))
supported_versions = tuple(range(8, 29))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v29 requis")
raise ValueError("schema desktop v8 à v28 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 30))
supported_versions = tuple(range(8, 29))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v29 requis")
raise ValueError("schema desktop v8 à v28 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise ValueError("schema desktop v12-v29 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise ValueError("schema desktop v12-v28 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 30))
supported_versions = tuple(range(11, 29))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 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():

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 30):
if version not in range(8, 29):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,9 +83,9 @@ 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):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 30)
supported_versions = range(11, 29)
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-v16 requis (v10 accepté pour export V2 explicite)")

View file

@ -16,8 +16,8 @@ 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):
raise ValueError("desktop schema v26, v27, v28 or v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28):
raise ValueError("desktop schema v26, v27 or v28 required")
programs = []
for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"

View file

@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29)
supported_versions = (24, 25, 26, 27, 28)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
raise ValueError("desktop schema v24, v25, v26, v27 or v28 required")
deliveries = []
rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 30)
supported_versions = range(15, 29)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v29 requis")
raise ValueError("schema desktop v15-v28 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -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, 30)
supported_versions = range(18, 29)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v29 requis")
raise ImportFailure("schema desktop v18-v28 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,7 +206,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 30)
supported_versions = range(8, 29)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export

View file

@ -53,7 +53,7 @@ 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, 30)
supported_versions = range(8, 29)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 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():

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 30)
supported_versions = range(12, 29)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 30)
supported_versions = range(11, 29)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v29 requis")
raise ImportFailure("schema desktop v11-v28 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
fail("schema desktop v17-v29 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
fail("schema desktop v17-v28 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 30):
if version not in range(5, 29):
raise ImportFailure(
f"base desktop schema v5 à v29 attendue, version trouvée: {version}"
f"base desktop schema v5 à v28 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):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 30)
supported_versions = range(15, 29)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v29 requis")
fail("schema desktop v15-v28 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -16,7 +16,6 @@ RUNTIME_TOOLS = [
"sync_peer_worker.py",
"sync_drive_transport.py",
"sync_generation_exchange.py",
"sleep_diary_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
"validate_training_knowledge.py",

View file

@ -87,8 +87,8 @@ 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):
fail("desktop schema v27, v28 or v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28):
fail("desktop schema v27 or v28 required")
added = 0
advanced = 0
skipped = 0

View file

@ -1,218 +0,0 @@
#!/usr/bin/env python3
"""Export, validate and merge the versioned Sleep Diary V1 companion."""
import argparse
import json
import re
import sqlite3
from datetime import date, datetime
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-sleep-diary"
VERSION = 1
MAX_BYTES = 16 * 1024 * 1024
MAX_ENTRIES = 3660
MAX_EVENTS = 64
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
ENTRY_ID = re.compile(rf"^sl_{UUID4}$")
REVISION_ID = re.compile(rf"^slr_{UUID4}$")
EVENT_ID = re.compile(rf"^sle_{UUID4}$")
POINTS = {"bed_time", "final_get_up", "night_get_up", "daytime_sleepiness"}
INTERVALS = {"sleep", "nap", "long_awake", "half_sleep"}
QUALITIES = {"TB", "B", "Moy", "M", "TM"}
def fail(message: str) -> None:
raise ValueError(message)
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
fail("duplicate JSON field: " + key)
result[key] = value
return result
def timestamp(value: object) -> str:
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
fail("invalid sleep diary timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("invalid sleep diary timestamp") from error
if parsed.utcoffset() is None:
fail("sleep diary timestamp lacks an offset")
return value
def validate(root: object) -> dict:
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "entries"}:
fail("invalid sleep diary envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported sleep diary format")
timestamp(root["generated_at"])
entries = root["entries"]
if not isinstance(entries, list) or len(entries) > MAX_ENTRIES:
fail("sleep diary entry bound exceeded")
seen_entries = set()
for item in entries:
keys = {"entry_id", "night_start_date", "night_end_date", "created_at", "updated_at",
"revision_id", "parent_revision_id", "deleted", "sleep_quality", "wake_quality",
"day_form", "treatment_and_notes", "events"}
if not isinstance(item, dict) or set(item) != keys:
fail("invalid sleep diary entry")
if not ENTRY_ID.fullmatch(item["entry_id"]) or item["entry_id"] in seen_entries:
fail("invalid or duplicate sleep diary entry identity")
seen_entries.add(item["entry_id"])
if not REVISION_ID.fullmatch(item["revision_id"]):
fail("invalid sleep diary revision identity")
parent = item["parent_revision_id"]
if parent is not None and (not isinstance(parent, str) or not REVISION_ID.fullmatch(parent) or parent == item["revision_id"]):
fail("invalid sleep diary parent revision")
try:
start_date = date.fromisoformat(item["night_start_date"])
end_date = date.fromisoformat(item["night_end_date"])
except (TypeError, ValueError) as error:
raise ValueError("invalid sleep diary date") from error
if start_date >= end_date:
fail("sleep diary date range is empty")
created = datetime.fromisoformat(timestamp(item["created_at"]).replace("Z", "+00:00"))
updated = datetime.fromisoformat(timestamp(item["updated_at"]).replace("Z", "+00:00"))
if updated < created or type(item["deleted"]) is not bool:
fail("invalid sleep diary lifecycle")
for name in ("sleep_quality", "wake_quality", "day_form"):
if item[name] is not None and item[name] not in QUALITIES:
fail("invalid sleep diary appreciation")
notes = item["treatment_and_notes"]
if not isinstance(notes, str) or len(notes.encode()) > 16384:
fail("sleep diary notes bound exceeded")
events = item["events"]
if not isinstance(events, list) or len(events) > MAX_EVENTS:
fail("sleep diary event bound exceeded")
seen_events = set()
for event in events:
if not isinstance(event, dict) or set(event) != {"event_id", "type", "start_at", "end_at"}:
fail("invalid sleep diary event")
if not EVENT_ID.fullmatch(event["event_id"]) or event["event_id"] in seen_events:
fail("invalid or duplicate sleep event identity")
seen_events.add(event["event_id"])
start = datetime.fromisoformat(timestamp(event["start_at"]).replace("Z", "+00:00"))
if event["type"] in POINTS:
if event["end_at"] is not None:
fail("point sleep event has an end")
elif event["type"] in INTERVALS:
end = datetime.fromisoformat(timestamp(event["end_at"]).replace("Z", "+00:00"))
if end <= start:
fail("sleep interval is not positive in absolute time")
else:
fail("unknown sleep diary event type")
return root
def load(path: Path) -> dict:
if path.stat().st_size > MAX_BYTES:
fail("sleep diary artifact exceeds 16 MiB")
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
def build(db: sqlite3.Connection) -> dict:
entries = []
rows = db.execute(
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted "
"FROM sleep_diary_entries ORDER BY entry_id"
).fetchall()
if len(rows) > MAX_ENTRIES:
fail("sleep diary entry bound exceeded")
for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows:
revision = db.execute(
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
).fetchone()
if revision is None:
fail("sleep diary current revision is missing")
events = [
{"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]}
for row in db.execute(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
)
]
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], "deleted": bool(deleted),
"sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
"treatment_and_notes": revision[4] or "", "events": events})
return validate({"format": FORMAT, "version": VERSION,
"generated_at": datetime.now().astimezone().isoformat(), "entries": entries})
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
validate(root)
applied = 0
unchanged = 0
for item in root["entries"]:
local = db.execute(
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
(item["entry_id"],),
).fetchone()
if local is not None and local[0] == item["revision_id"]:
persisted = next(
value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"]
)
if persisted != item:
fail("sleep diary revision identity reused with different content")
unchanged += 1
continue
if local is not None and item["parent_revision_id"] != local[0]:
fail("concurrent sleep diary revision")
if local is None and item["parent_revision_id"] is not None:
fail("sleep diary revision has an unknown parent")
if local is not None and local[1] and not item["deleted"]:
fail("sleep diary deletion cannot be resurrected")
if local is None:
db.execute("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)",
(item["entry_id"], item["night_start_date"], item["night_end_date"],
item["created_at"], item["updated_at"], item["revision_id"], int(item["deleted"])))
else:
db.execute("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,"
"current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
(item["night_start_date"], item["night_end_date"], item["updated_at"],
item["revision_id"], int(item["deleted"]), item["entry_id"], local[0]))
db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
(item["revision_id"], item["entry_id"], item["parent_revision_id"], item["updated_at"],
item["sleep_quality"], item["wake_quality"], item["day_form"], item["treatment_and_notes"]))
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"]])
applied += 1
return applied, unchanged
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
export = sub.add_parser("export")
export.add_argument("output", type=Path)
export.add_argument("--database", required=True, type=Path)
import_command = sub.add_parser("import")
import_command.add_argument("input", type=Path)
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] != 29:
fail("desktop schema v29 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")
else:
apply(db, load(args.input))
db.commit()
if __name__ == "__main__":
main()

View file

@ -29,7 +29,6 @@ import import_exercise_profile_state
import import_mobile_export
import import_training_feedback
import program_execution_exchange
import sleep_diary_exchange
import export_equipment_definitions
import export_exercise_body_zones
import export_pc_mobile
@ -76,8 +75,6 @@ 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_exchange.py", ("export",)),
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1),
@ -209,9 +206,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)
supported_versions = (24, 25, 26, 27, 28)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
raise GenerationError("desktop schema v24, v25, v26, v27 or v28 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -328,8 +325,7 @@ def archive_acknowledged(database: Path, owned_root: Path, consumer: str) -> int
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
command = [sys.executable, str(ROOT / "tools" / tool)]
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py",
"sleep_diary_exchange.py"):
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
command.extend(extra); command.append(str(output))
else:
command.append(str(output)); command.extend(extra)
@ -614,8 +610,6 @@ def consume_desktop(database: Path, directory: Path) -> dict:
zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
feedback = import_training_feedback.load(required("feedback"))
sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"])
if "sleep-diary" in listed else None)
program_executions = (
program_execution_exchange.load(listed["program-executions"])
if "program-executions" in listed else None
@ -657,8 +651,6 @@ def consume_desktop(database: Path, directory: Path) -> dict:
import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True)
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
if sleep_diary is not None:
sleep_diary_exchange.apply(db, sleep_diary)
if program_executions is not None:
program_execution_exchange.apply_executions(db, program_executions)
ack = record_consumed(db, manifest, manifest_digest)

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29
#define TRAINLOG_DATABASE_SCHEMA_VERSION 28
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -44,7 +44,6 @@
* occurrence, delivery, and withdrawal identities;
* - `pgr` for desktop Program V1 revision identities;
* - `del` for immutable causal deletion operations.
* - `sl`, `slr`, and `sle` for sleep diary entries, revisions, and events.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.

View file

@ -1,100 +0,0 @@
/* Trainlog Sleep Diary V1 domain API. */
#ifndef TRAINLOG_SLEEP_DIARY_H
#define TRAINLOG_SLEEP_DIARY_H
#include <stdbool.h>
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_SLEEP_EVENTS_MAX 64U
#define TRAINLOG_SLEEP_NOTES_MAX 16384U
#define TRAINLOG_SLEEP_ENTRY_ID_CAPACITY 40U
#define TRAINLOG_SLEEP_REVISION_ID_CAPACITY 41U
#define TRAINLOG_SLEEP_EVENT_ID_CAPACITY 41U
#define TRAINLOG_SLEEP_TIMESTAMP_CAPACITY 64U
typedef enum TrainlogSleepQuality {
TRAINLOG_SLEEP_QUALITY_UNSET = 0,
TRAINLOG_SLEEP_QUALITY_TB,
TRAINLOG_SLEEP_QUALITY_B,
TRAINLOG_SLEEP_QUALITY_MOY,
TRAINLOG_SLEEP_QUALITY_M,
TRAINLOG_SLEEP_QUALITY_TM
} TrainlogSleepQuality;
typedef enum TrainlogSleepEventType {
TRAINLOG_SLEEP_EVENT_BED_TIME = 0,
TRAINLOG_SLEEP_EVENT_FINAL_GET_UP,
TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP,
TRAINLOG_SLEEP_EVENT_SLEEP,
TRAINLOG_SLEEP_EVENT_NAP,
TRAINLOG_SLEEP_EVENT_LONG_AWAKE,
TRAINLOG_SLEEP_EVENT_HALF_SLEEP,
TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
} TrainlogSleepEventType;
typedef struct TrainlogSleepEvent {
char event_id[TRAINLOG_SLEEP_EVENT_ID_CAPACITY];
TrainlogSleepEventType type;
char start_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char end_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
} TrainlogSleepEvent;
typedef struct TrainlogSleepDiaryEntry {
char entry_id[TRAINLOG_SLEEP_ENTRY_ID_CAPACITY];
char night_start_date[11];
char night_end_date[11];
char created_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char updated_at[TRAINLOG_SLEEP_TIMESTAMP_CAPACITY];
char revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
char parent_revision_id[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
TrainlogSleepQuality sleep_quality;
TrainlogSleepQuality wake_quality;
TrainlogSleepQuality day_form;
char treatment_and_notes[TRAINLOG_SLEEP_NOTES_MAX + 1U];
TrainlogSleepEvent events[TRAINLOG_SLEEP_EVENTS_MAX];
size_t event_count;
bool deleted;
} TrainlogSleepDiaryEntry;
typedef TrainlogStatus (*TrainlogSleepDiaryVisitor)(void *context,
const TrainlogSleepDiaryEntry *entry);
/* CONTRACT: timestamps are ISO-8601 instants carrying Z or a numeric offset.
* Intervals are ordered by absolute time, never lexically or by HH:MM. */
bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry);
/* Creates one stable identity and initial immutable revision transactionally. */
TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database,
TrainlogSleepDiaryEntry *entry);
/* Replaces the complete current snapshot iff expected_revision is current.
* Exact replay of an already-current resulting revision is idempotent. */
TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database,
const char *expected_revision,
TrainlogSleepDiaryEntry *entry);
TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
const char *entry_id,
bool include_deleted,
TrainlogSleepDiaryEntry *output);
/* Dates are inclusive YYYY-MM-DD bounds; NULL means unbounded. */
TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database,
const char *start_date,
const char *end_date,
size_t limit,
TrainlogSleepDiaryVisitor visitor,
void *context);
/* Logical deletion is revision guarded and retained for sync replay safety. */
TrainlogStatus
trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]);
#endif

View file

@ -14,8 +14,6 @@
typedef enum TrainlogWebAnalysisPeriod {
TRAINLOG_WEB_ANALYSIS_7_DAYS = 7,
TRAINLOG_WEB_ANALYSIS_14_DAYS = 14,
TRAINLOG_WEB_ANALYSIS_21_DAYS = 21,
TRAINLOG_WEB_ANALYSIS_30_DAYS = 30,
TRAINLOG_WEB_ANALYSIS_90_DAYS = 90,
TRAINLOG_WEB_ANALYSIS_ALL = 0

View file

@ -1,40 +0,0 @@
#ifndef TRAINLOG_WEB_SLEEP_H
#define TRAINLOG_WEB_SLEEP_H
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
#define TRAINLOG_WEB_SLEEP_BYTES_MAX (512U * 1024U)
/* Successful calls transfer one free()-owned UTF-8 JSON allocation. */
TrainlogStatus trainlog_web_sleep_list_json(TrainlogDatabase *database,
const char *start_date,
const char *end_date,
size_t limit,
char **output_json,
size_t *output_size);
/* expected_revision is NULL only for creation. The complete snapshot is
* validated before the Core starts its transaction. */
TrainlogStatus trainlog_web_sleep_save_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_sleep_delete_json(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *deleted_at,
char **output_json,
size_t *output_size);
TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size);
#endif

View file

@ -48,7 +48,6 @@ if web_assets_enabled
'../web/src/api/health.ts',
'../web/src/api/dashboard.ts',
'../web/src/api/analysis.ts',
'../web/src/api/sleepDiary.ts',
'../web/src/api/analysis.test.ts',
'../web/src/analysis/messages.ts',
'../web/src/api/dashboardLayout.ts',
@ -78,9 +77,6 @@ if web_assets_enabled
'../web/src/presentation/dateFormat.ts',
'../web/src/routes/DashboardPage.tsx',
'../web/src/routes/AnalysisPage.tsx',
'../web/src/routes/SleepDiaryWorkspace.tsx',
'../web/src/report/sleepDiaryPdf.ts',
'../web/src/report/sleepDiaryPdf.test.ts',
'../web/src/routes/AnalysisPage.test.tsx',
'../web/src/routes/PlaceholderPage.tsx',
'../web/src/routes/SessionsPage.tsx',
@ -223,7 +219,6 @@ trainlog_core_sources = files(
'src/web_sessions.c',
'src/web_session_deletions.c',
'src/web_programs.c',
'src/web_sleep.c',
'src/web_exercises.c',
'src/duration.c',
'src/id.c',
@ -234,7 +229,6 @@ trainlog_core_sources = files(
'src/generation_mtp.c',
'src/reps.c',
'src/session_generation.c',
'src/sleep_diary.c',
'src/statistics.c',
'src/measured_max.c',
'src/sync.c',
@ -807,22 +801,6 @@ test_schema_v28_migration = executable(
test('schema_v28_migration', test_schema_v28_migration)
test_sleep_diary = executable(
'test_sleep_diary',
'tests/test_sleep_diary.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('sleep_diary', test_sleep_diary)
test_schema_v29_migration = executable(
'test_schema_v29_migration',
'tests/test_schema_v29_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v29_migration', test_schema_v29_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -897,11 +875,6 @@ test(
args: [meson.project_source_root() / 'tests/test_sync_generation_exchange.py'],
timeout: 120,
)
test(
'sleep_diary_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
)
test(
'session_preparation_export',
find_program('python3'),

View file

@ -1274,41 +1274,6 @@ static const char *const MIGRATE_V27_TO_V28_SQL =
"ON program_session_executions(program_id,state);"
"PRAGMA user_version=28;COMMIT;PRAGMA foreign_keys=ON;";
/* WHY: sleep observations need minute-precise, timezone-aware facts that can
* later be correlated with physiological samples; a rendered 18:00 timeline
* is not a persistence model. CONTRACT: one stable diary identity owns a
* bounded causal revision chain and zero or more typed points/intervals.
* INVARIANT: wall-clock timestamps never order concurrent peer revisions and
* logical deletion retains the last revision for causal replay protection. */
static const char *const MIGRATE_V28_TO_V29_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS sleep_diary_entries("
"entry_id TEXT PRIMARY KEY,night_start_date TEXT NOT NULL,night_end_date TEXT NOT NULL,"
"created_at TEXT NOT NULL,updated_at TEXT NOT NULL,current_revision_id TEXT NOT NULL,"
"deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN(0,1)),"
"CHECK(entry_id GLOB 'sl_*'),CHECK(night_start_date<night_end_date));"
"CREATE TABLE IF NOT EXISTS sleep_diary_revisions("
"revision_id TEXT PRIMARY KEY,entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) "
"ON DELETE RESTRICT,parent_revision_id TEXT,created_at TEXT NOT NULL,"
"sleep_quality TEXT CHECK(sleep_quality IN('TB','B','Moy','M','TM')),"
"wake_quality TEXT CHECK(wake_quality IN('TB','B','Moy','M','TM')),"
"day_form TEXT CHECK(day_form IN('TB','B','Moy','M','TM')),"
"treatment_and_notes TEXT,"
"CHECK(treatment_and_notes IS NULL OR length(CAST(treatment_and_notes AS BLOB))<=16384),"
"UNIQUE(entry_id,revision_id));"
"CREATE TABLE IF NOT EXISTS sleep_diary_events("
"revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,"
"event_id TEXT NOT NULL,event_type TEXT NOT NULL CHECK(event_type IN("
"'bed_time','final_get_up','night_get_up','sleep','nap','long_awake','half_sleep',"
"'daytime_sleepiness')),start_at TEXT NOT NULL,end_at TEXT,"
"CHECK((event_type IN('bed_time','final_get_up','night_get_up','daytime_sleepiness') AND "
"end_at IS NULL) OR (event_type IN('sleep','nap','long_awake','half_sleep') AND "
"end_at IS NOT NULL)),PRIMARY KEY(revision_id,event_id));"
"CREATE INDEX IF NOT EXISTS sleep_diary_entries_dates ON "
"sleep_diary_entries(night_start_date,deleted);"
"CREATE INDEX IF NOT EXISTS sleep_diary_events_time ON sleep_diary_events(start_at,end_at);"
"PRAGMA user_version=29;COMMIT;";
/* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when
* absent; a genuine v24 database still receives both before opening returns. */
@ -2098,7 +2063,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
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 == 26 || version == 27 || version == 28) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2240,9 +2205,6 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 28) {
status = execute_sql(database, MIGRATE_V27_TO_V28_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 29) {
status = execute_sql(database, MIGRATE_V28_TO_V29_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2262,7 +2224,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 v29" : "migrate database to schema v29",
version == 0 ? "create schema v28" : "migrate database to schema v28",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -20,8 +20,7 @@ static bool prefix_is_supported(const char *prefix) {
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0 ||
strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0;
strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0;
}
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {

View file

@ -1,586 +0,0 @@
#include "trainlog/sleep_diary.h"
#include <stdio.h>
#include <string.h>
#include <sqlite3.h>
#include "database_internal.h"
#include "timestamp.h"
#include "trainlog/id.h"
static bool date_valid(const char *value) {
TrainlogTimestampKey key;
char timestamp[32];
int written;
if (value == NULL || strlen(value) != 10U) {
return false;
}
written = snprintf(timestamp, sizeof(timestamp), "%sT00:00:00Z", value);
return written == 20 && trainlog_timestamp_parse(timestamp, 20U, &key);
}
static bool id_valid(const char *value, const char *prefix, size_t capacity) {
size_t prefix_size = strlen(prefix);
return value != NULL && strlen(value) < capacity && strncmp(value, prefix, prefix_size) == 0 &&
value[prefix_size] == '_' &&
strlen(value + prefix_size + 1U) == TRAINLOG_UUID_TEXT_LENGTH;
}
static const char *quality_text(TrainlogSleepQuality value) {
static const char *const values[] = {NULL, "TB", "B", "Moy", "M", "TM"};
return value >= TRAINLOG_SLEEP_QUALITY_UNSET && value <= TRAINLOG_SLEEP_QUALITY_TM
? values[value]
: NULL;
}
static bool quality_parse(const unsigned char *value, TrainlogSleepQuality *output) {
size_t index;
if (value == NULL) {
*output = TRAINLOG_SLEEP_QUALITY_UNSET;
return true;
}
for (index = 1U; index <= (size_t)TRAINLOG_SLEEP_QUALITY_TM; ++index) {
if (strcmp((const char *)value, quality_text((TrainlogSleepQuality)index)) == 0) {
*output = (TrainlogSleepQuality)index;
return true;
}
}
return false;
}
static const char *event_text(TrainlogSleepEventType type) {
static const char *const values[] = {"bed_time",
"final_get_up",
"night_get_up",
"sleep",
"nap",
"long_awake",
"half_sleep",
"daytime_sleepiness"};
return type >= TRAINLOG_SLEEP_EVENT_BED_TIME && type <= TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
? values[type]
: NULL;
}
static bool event_parse(const unsigned char *value, TrainlogSleepEventType *output) {
int index;
if (value == NULL) {
return false;
}
for (index = 0; index <= (int)TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; ++index) {
if (strcmp((const char *)value, event_text((TrainlogSleepEventType)index)) == 0) {
*output = (TrainlogSleepEventType)index;
return true;
}
}
return false;
}
static bool point_type(TrainlogSleepEventType type) {
return type == TRAINLOG_SLEEP_EVENT_BED_TIME || type == TRAINLOG_SLEEP_EVENT_FINAL_GET_UP ||
type == TRAINLOG_SLEEP_EVENT_NIGHT_GET_UP ||
type == TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS;
}
static bool timestamp_valid(const char *value, TrainlogTimestampKey *key) {
size_t length = value == NULL ? 0U : strlen(value);
return length > 0U && length < TRAINLOG_SLEEP_TIMESTAMP_CAPACITY &&
trainlog_timestamp_parse(value, length, key);
}
bool trainlog_sleep_diary_validate(const TrainlogSleepDiaryEntry *entry) {
size_t index, other;
TrainlogTimestampKey created, updated;
if (entry == NULL || !date_valid(entry->night_start_date) ||
!date_valid(entry->night_end_date) ||
strcmp(entry->night_start_date, entry->night_end_date) >= 0 ||
!timestamp_valid(entry->created_at, &created) ||
!timestamp_valid(entry->updated_at, &updated) ||
trainlog_timestamp_compare(&updated, &created) < 0 ||
entry->sleep_quality > TRAINLOG_SLEEP_QUALITY_TM ||
entry->wake_quality > TRAINLOG_SLEEP_QUALITY_TM ||
entry->day_form > TRAINLOG_SLEEP_QUALITY_TM ||
strlen(entry->treatment_and_notes) > TRAINLOG_SLEEP_NOTES_MAX ||
entry->event_count > TRAINLOG_SLEEP_EVENTS_MAX) {
return false;
}
for (index = 0U; index < entry->event_count; ++index) {
const TrainlogSleepEvent *event = &entry->events[index];
TrainlogTimestampKey start, end;
bool point = point_type(event->type);
if (!id_valid(event->event_id, "sle", sizeof(event->event_id)) ||
event_text(event->type) == NULL || !timestamp_valid(event->start_at, &start) ||
(point ? event->end_at[0] != '\0'
: !timestamp_valid(event->end_at, &end) ||
trainlog_timestamp_compare(&end, &start) <= 0)) {
return false;
}
for (other = index + 1U; other < entry->event_count; ++other) {
if (strcmp(event->event_id, entry->events[other].event_id) == 0) {
return false;
}
}
}
return true;
}
static void rollback(sqlite3 *database) {
(void)sqlite3_exec(database, "ROLLBACK", NULL, NULL, NULL);
}
static TrainlogStatus insert_revision(TrainlogDatabase *database,
const TrainlogSleepDiaryEntry *entry) {
static const char revision_sql[] =
"INSERT INTO sleep_diary_revisions(revision_id,entry_id,parent_revision_id,created_at,"
"sleep_quality,wake_quality,day_form,treatment_and_notes) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)";
static const char event_sql[] =
"INSERT INTO sleep_diary_events(revision_id,event_id,event_type,start_at,end_at) "
"VALUES(?1,?2,?3,?4,?5)";
sqlite3_stmt *statement = NULL;
size_t index;
int result = sqlite3_prepare_v2(database->connection, revision_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry->entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result =
entry->parent_revision_id[0] == '\0'
? sqlite3_bind_null(statement, 3)
: sqlite3_bind_text(statement, 3, entry->parent_revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, entry->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = entry->sleep_quality == TRAINLOG_SLEEP_QUALITY_UNSET
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(
statement, 5, quality_text(entry->sleep_quality), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result = entry->wake_quality == TRAINLOG_SLEEP_QUALITY_UNSET
? sqlite3_bind_null(statement, 6)
: sqlite3_bind_text(
statement, 6, quality_text(entry->wake_quality), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result =
entry->day_form == TRAINLOG_SLEEP_QUALITY_UNSET
? sqlite3_bind_null(statement, 7)
: sqlite3_bind_text(statement, 7, quality_text(entry->day_form), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 8, entry->treatment_and_notes, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
if (result != SQLITE_OK) {
return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
for (index = 0U; index < entry->event_count; ++index) {
const TrainlogSleepEvent *event = &entry->events[index];
statement = NULL;
result = sqlite3_prepare_v2(database->connection, event_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, event->event_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, event_text(event->type), -1, SQLITE_STATIC);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, event->start_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = point_type(event->type)
? sqlite3_bind_null(statement, 5)
: sqlite3_bind_text(statement, 5, event->end_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
if (result != SQLITE_OK) {
return result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR;
}
}
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_sleep_diary_create(TrainlogDatabase *database,
TrainlogSleepDiaryEntry *entry) {
static const char sql[] = "INSERT INTO sleep_diary_entries VALUES(?1,?2,?3,?4,?5,?6,0)";
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
int result;
if (database == NULL || entry == NULL || entry->deleted || entry->entry_id[0] != '\0' ||
entry->revision_id[0] != '\0') {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (trainlog_id_generate("sl", entry->entry_id, sizeof(entry->entry_id)) !=
TRAINLOG_STATUS_OK ||
trainlog_id_generate("slr", entry->revision_id, sizeof(entry->revision_id)) !=
TRAINLOG_STATUS_OK ||
!trainlog_sleep_diary_validate(entry)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry->night_start_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, entry->night_end_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, entry->created_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, entry->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
status = result == SQLITE_OK ? insert_revision(database, entry)
: (result == SQLITE_CONSTRAINT ? TRAINLOG_STATUS_CONFLICT
: TRAINLOG_STATUS_DATABASE_ERROR);
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
TrainlogStatus trainlog_sleep_diary_update(TrainlogDatabase *database,
const char *expected_revision,
TrainlogSleepDiaryEntry *entry) {
static const char sql[] =
"UPDATE sleep_diary_entries SET "
"night_start_date=?1,night_end_date=?2,updated_at=?3,current_revision_id=?4 WHERE "
"entry_id=?5 AND current_revision_id=?6 AND deleted=0";
sqlite3_stmt *statement = NULL;
TrainlogStatus status;
char next[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
int result;
if (database == NULL || entry == NULL ||
!id_valid(entry->entry_id, "sl", sizeof(entry->entry_id)) ||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY) ||
entry->deleted) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (strcmp(entry->revision_id, expected_revision) != 0 ||
trainlog_id_generate("slr", next, sizeof(next)) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(
entry->parent_revision_id, sizeof(entry->parent_revision_id), "%s", expected_revision);
(void)snprintf(entry->revision_id, sizeof(entry->revision_id), "%s", next);
if (!trainlog_sleep_diary_validate(entry)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = insert_revision(database, entry);
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry->night_start_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry->night_end_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, entry->updated_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, entry->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, entry->entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT;
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
static TrainlogStatus load_entry(TrainlogDatabase *database,
const char *entry_id,
bool include_deleted,
TrainlogSleepDiaryEntry *output) {
static const char root_sql[] =
"SELECT "
"e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.current_"
"revision_id,e.deleted,r.parent_revision_id,r.sleep_quality,r.wake_quality,r.day_form,r."
"treatment_and_notes FROM sleep_diary_entries e JOIN sleep_diary_revisions r ON "
"r.revision_id=e.current_revision_id WHERE e.entry_id=?1 AND (?2 OR e.deleted=0)";
static const char events_sql[] =
"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";
sqlite3_stmt *statement = NULL;
int result;
memset(output, 0, sizeof(*output));
result = sqlite3_prepare_v2(database->connection, root_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int(statement, 2, include_deleted ? 1 : 0);
}
if (result != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return result == SQLITE_OK ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
}
#define COPY_COLUMN(field, column) \
(void)snprintf(output->field, \
sizeof(output->field), \
"%s", \
sqlite3_column_text(statement, column) == NULL \
? "" \
: (const char *)sqlite3_column_text(statement, column))
COPY_COLUMN(entry_id, 0);
COPY_COLUMN(night_start_date, 1);
COPY_COLUMN(night_end_date, 2);
COPY_COLUMN(created_at, 3);
COPY_COLUMN(updated_at, 4);
COPY_COLUMN(revision_id, 5);
output->deleted = sqlite3_column_int(statement, 6) != 0;
COPY_COLUMN(parent_revision_id, 7);
#undef COPY_COLUMN
if (!quality_parse(sqlite3_column_text(statement, 8), &output->sleep_quality) ||
!quality_parse(sqlite3_column_text(statement, 9), &output->wake_quality) ||
!quality_parse(sqlite3_column_text(statement, 10), &output->day_form)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)snprintf(output->treatment_and_notes,
sizeof(output->treatment_and_notes),
"%s",
sqlite3_column_text(statement, 11) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 11));
if (sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
result = sqlite3_prepare_v2(database->connection, events_sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, output->revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
while (result == SQLITE_ROW) {
TrainlogSleepEvent *event;
if (output->event_count == TRAINLOG_SLEEP_EVENTS_MAX) {
result = SQLITE_TOOBIG;
break;
}
event = &output->events[output->event_count++];
(void)snprintf(
event->event_id, sizeof(event->event_id), "%s", sqlite3_column_text(statement, 0));
if (!event_parse(sqlite3_column_text(statement, 1), &event->type)) {
result = SQLITE_MISMATCH;
break;
}
(void)snprintf(
event->start_at, sizeof(event->start_at), "%s", sqlite3_column_text(statement, 2));
(void)snprintf(event->end_at,
sizeof(event->end_at),
"%s",
sqlite3_column_text(statement, 3) == NULL
? ""
: (const char *)sqlite3_column_text(statement, 3));
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
return result == SQLITE_DONE && trainlog_sleep_diary_validate(output)
? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus trainlog_sleep_diary_get(TrainlogDatabase *database,
const char *entry_id,
bool include_deleted,
TrainlogSleepDiaryEntry *output) {
if (database == NULL || output == NULL ||
!id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
return load_entry(database, entry_id, include_deleted, output);
}
TrainlogStatus trainlog_sleep_diary_list(TrainlogDatabase *database,
const char *start_date,
const char *end_date,
size_t limit,
TrainlogSleepDiaryVisitor visitor,
void *context) {
static const char sql[] =
"SELECT entry_id FROM sleep_diary_entries WHERE deleted=0 AND (?1 IS NULL OR "
"night_start_date>=?1) AND (?2 IS NULL OR night_start_date<=?2) ORDER BY night_start_date "
"DESC,entry_id COLLATE BINARY LIMIT ?3";
sqlite3_stmt *statement = NULL;
int result;
if (database == NULL || visitor == NULL || limit == 0U || limit > 3660U ||
(start_date != NULL && !date_valid(start_date)) ||
(end_date != NULL && !date_valid(end_date)) ||
(start_date != NULL && end_date != NULL && strcmp(start_date, end_date) > 0)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = start_date == NULL
? sqlite3_bind_null(statement, 1)
: sqlite3_bind_text(statement, 1, start_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = end_date == NULL ? sqlite3_bind_null(statement, 2)
: sqlite3_bind_text(statement, 2, end_date, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_int64(statement, 3, (sqlite3_int64)limit);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
while (result == SQLITE_ROW) {
TrainlogSleepDiaryEntry entry;
TrainlogStatus status =
load_entry(database, (const char *)sqlite3_column_text(statement, 0), false, &entry);
if (status == TRAINLOG_STATUS_OK) {
status = visitor(context, &entry);
}
if (status != TRAINLOG_STATUS_OK) {
(void)sqlite3_finalize(statement);
return status;
}
result = sqlite3_step(statement);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
result = SQLITE_ERROR;
}
return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
TrainlogStatus
trainlog_sleep_diary_delete(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *deleted_at,
char output_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]) {
static const char sql[] =
"UPDATE sleep_diary_entries SET deleted=1,updated_at=?1,current_revision_id=?2 WHERE "
"entry_id=?3 AND current_revision_id=?4 AND deleted=0";
sqlite3_stmt *statement = NULL;
TrainlogSleepDiaryEntry entry;
TrainlogStatus status;
int result;
if (database == NULL || output_revision == NULL ||
!id_valid(entry_id, "sl", TRAINLOG_SLEEP_ENTRY_ID_CAPACITY) ||
!id_valid(expected_revision, "slr", TRAINLOG_SLEEP_REVISION_ID_CAPACITY)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = load_entry(database, entry_id, false, &entry);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (strcmp(entry.revision_id, expected_revision) != 0) {
return TRAINLOG_STATUS_CONFLICT;
}
(void)snprintf(
entry.parent_revision_id, sizeof(entry.parent_revision_id), "%s", expected_revision);
if (trainlog_id_generate("slr", entry.revision_id, sizeof(entry.revision_id)) !=
TRAINLOG_STATUS_OK ||
!timestamp_valid(deleted_at, &(TrainlogTimestampKey){0})) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", deleted_at);
entry.deleted = true;
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
status = insert_revision(database, &entry);
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, deleted_at, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, entry.revision_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, entry_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK
: sqlite3_errcode(database->connection);
}
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_OK) {
result = SQLITE_ERROR;
}
status = result != SQLITE_OK ? TRAINLOG_STATUS_DATABASE_ERROR
: sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_CONFLICT;
}
if (status == TRAINLOG_STATUS_OK &&
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
(void)snprintf(
output_revision, TRAINLOG_SLEEP_REVISION_ID_CAPACITY, "%s", entry.revision_id);
return status;
}
rollback(database->connection);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}

View file

@ -544,8 +544,6 @@ TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database,
if (database == NULL || query == NULL || output == NULL || capacity == 0U ||
output_size == NULL || query->reference_unix_second < 0 ||
(query->period != TRAINLOG_WEB_ANALYSIS_7_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_14_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_21_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_30_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_90_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_ALL) ||
@ -570,8 +568,6 @@ TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database,
"period",
query->period == TRAINLOG_WEB_ANALYSIS_ALL ? "all"
: query->period == TRAINLOG_WEB_ANALYSIS_7_DAYS ? "7d"
: query->period == TRAINLOG_WEB_ANALYSIS_14_DAYS ? "14d"
: query->period == TRAINLOG_WEB_ANALYSIS_21_DAYS ? "21d"
: query->period == TRAINLOG_WEB_ANALYSIS_30_DAYS ? "30d"
: "90d") ||
!add_overview(database, query, document, root) ||

View file

@ -27,7 +27,6 @@
#include "trainlog/web_programs.h"
#include "trainlog/web_session_deletions.h"
#include "trainlog/web_sessions.h"
#include "trainlog/web_sleep.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_preferences.h"
#include "web_assets.h"
@ -116,14 +115,6 @@ static bool parse_analysis_period(const char *value, TrainlogWebAnalysisPeriod *
*period = TRAINLOG_WEB_ANALYSIS_7_DAYS;
return true;
}
if (strcmp(value, "14d") == 0) {
*period = TRAINLOG_WEB_ANALYSIS_14_DAYS;
return true;
}
if (strcmp(value, "21d") == 0) {
*period = TRAINLOG_WEB_ANALYSIS_21_DAYS;
return true;
}
if (strcmp(value, "90d") == 0) {
*period = TRAINLOG_WEB_ANALYSIS_90_DAYS;
return true;
@ -1407,70 +1398,6 @@ static enum MHD_Result handle_request(void *closure,
}
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strcmp(url, "/api/v1/sleep-diary") == 0) {
char *json = NULL;
size_t json_size = 0U;
TrainlogStatus status;
enum MHD_Result queued;
if (is_get) {
const char *start =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start_date");
const char *end =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end_date");
size_t limit;
if (!parse_page_number(
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "limit"),
90U,
3660U,
&limit) ||
limit == 0U) {
return queue_json(connection,
MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_sleep_query\"}\n",
NULL);
}
status = trainlog_web_sleep_list_json(
context->database, start, end, limit, &json, &json_size);
} else if (strcmp(method, MHD_HTTP_METHOD_POST) == 0 ||
strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) {
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = strcmp(method, MHD_HTTP_METHOD_POST) == 0
? trainlog_web_sleep_save_json(
context->database, state->body, state->body_size, &json, &json_size)
: trainlog_web_sleep_delete_request_json(
context->database, state->body, state->body_size, &json, &json_size);
} else {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"GET, POST, DELETE");
}
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"revision_conflict\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
free(json);
return queue_json(connection,
MHD_HTTP_UNPROCESSABLE_CONTENT,
"{\"error\":\"invalid_sleep_diary\"}\n",
NULL);
}
if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) {
free(json);
return queue_json(connection,
MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"sleep_diary_unavailable\"}\n",
NULL);
}
queued = queue_json(connection, MHD_HTTP_OK, json, NULL);
free(json);
return queued;
}
if (strcmp(url, "/api/v1/dashboard") == 0) {
TrainlogWebDashboardQuery query;
TrainlogWebDashboardSnapshot snapshot;

View file

@ -1,488 +0,0 @@
#include "trainlog/web_sleep.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <yyjson.h>
#include "trainlog/id.h"
#include "trainlog/sleep_diary.h"
#include "timestamp.h"
typedef struct SleepListContext {
yyjson_mut_doc *document;
yyjson_mut_val *items;
size_t count;
int64_t sleep_seconds;
int64_t long_awake_seconds;
int64_t nap_seconds;
size_t long_awake_count;
size_t nap_count;
size_t sleepiness_count;
size_t quality[3][5];
int64_t bed_minute_total;
int64_t get_up_minute_total;
size_t bed_count;
size_t get_up_count;
} SleepListContext;
static bool event_duration(const TrainlogSleepEvent *event, int64_t *seconds) {
TrainlogTimestampKey start;
TrainlogTimestampKey end;
if (event->end_at[0] == '\0' ||
!trainlog_timestamp_parse(event->start_at, strlen(event->start_at), &start) ||
!trainlog_timestamp_parse(event->end_at, strlen(event->end_at), &end) ||
end.utc_second <= start.utc_second) {
return false;
}
*seconds = end.utc_second - start.utc_second;
return true;
}
/* WHY: ordinary HH:MM arithmetic maps 23:30 and 00:30 to noon. CONTRACT:
* analysis projects clock readings onto the diary's 18:00 -> 18:00 axis.
* INVARIANT: storage remains an absolute offset-bearing timestamp. */
static bool diary_minute(const char *timestamp, int64_t *minute) {
const char *separator = strchr(timestamp, 'T');
int hour;
int value;
if (separator == NULL || sscanf(separator + 1, "%2d:%2d", &hour, &value) != 2 || hour < 0 ||
hour > 23 || value < 0 || value > 59) {
return false;
}
*minute = (int64_t)((hour < 18 ? hour + 24 : hour) * 60 + value) - 18 * 60;
return true;
}
static const char *quality_text(TrainlogSleepQuality quality) {
static const char *const values[] = {NULL, "TB", "B", "Moy", "M", "TM"};
return quality >= TRAINLOG_SLEEP_QUALITY_UNSET && quality <= TRAINLOG_SLEEP_QUALITY_TM
? values[quality]
: NULL;
}
static bool quality_value(yyjson_val *value, TrainlogSleepQuality *output) {
int index;
if (yyjson_is_null(value)) {
*output = TRAINLOG_SLEEP_QUALITY_UNSET;
return true;
}
if (!yyjson_is_str(value)) {
return false;
}
for (index = 1; index <= (int)TRAINLOG_SLEEP_QUALITY_TM; ++index) {
if (strcmp(yyjson_get_str(value), quality_text((TrainlogSleepQuality)index)) == 0) {
*output = (TrainlogSleepQuality)index;
return true;
}
}
return false;
}
static const char *event_text(TrainlogSleepEventType type) {
static const char *const values[] = {"bed_time",
"final_get_up",
"night_get_up",
"sleep",
"nap",
"long_awake",
"half_sleep",
"daytime_sleepiness"};
return type >= TRAINLOG_SLEEP_EVENT_BED_TIME && type <= TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS
? values[type]
: NULL;
}
static bool event_value(yyjson_val *value, TrainlogSleepEventType *output) {
int index;
if (!yyjson_is_str(value)) {
return false;
}
for (index = 0; index <= (int)TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS; ++index) {
if (strcmp(yyjson_get_str(value), event_text((TrainlogSleepEventType)index)) == 0) {
*output = (TrainlogSleepEventType)index;
return true;
}
}
return false;
}
static bool add_nullable_quality(yyjson_mut_doc *document,
yyjson_mut_val *object,
const char *key,
TrainlogSleepQuality quality) {
return quality == TRAINLOG_SLEEP_QUALITY_UNSET
? yyjson_mut_obj_add_null(document, object, key)
: yyjson_mut_obj_add_strcpy(document, object, key, quality_text(quality));
}
static bool
add_entry(yyjson_mut_doc *document, yyjson_mut_val *items, const TrainlogSleepDiaryEntry *entry) {
yyjson_mut_val *object = yyjson_mut_obj(document);
yyjson_mut_val *events = yyjson_mut_arr(document);
size_t index;
if (object == NULL || events == NULL ||
!yyjson_mut_obj_add_strcpy(document, object, "entry_id", entry->entry_id) ||
!yyjson_mut_obj_add_strcpy(document, object, "night_start_date", entry->night_start_date) ||
!yyjson_mut_obj_add_strcpy(document, object, "night_end_date", entry->night_end_date) ||
!yyjson_mut_obj_add_strcpy(document, object, "created_at", entry->created_at) ||
!yyjson_mut_obj_add_strcpy(document, object, "updated_at", entry->updated_at) ||
!yyjson_mut_obj_add_strcpy(document, object, "revision_id", entry->revision_id) ||
!add_nullable_quality(document, object, "sleep_quality", entry->sleep_quality) ||
!add_nullable_quality(document, object, "wake_quality", entry->wake_quality) ||
!add_nullable_quality(document, object, "day_form", entry->day_form) ||
!yyjson_mut_obj_add_strcpy(
document, object, "treatment_and_notes", entry->treatment_and_notes)) {
return false;
}
for (index = 0U; index < entry->event_count; ++index) {
const TrainlogSleepEvent *event = &entry->events[index];
yyjson_mut_val *item = yyjson_mut_obj(document);
if (item == NULL ||
!yyjson_mut_obj_add_strcpy(document, item, "event_id", event->event_id) ||
!yyjson_mut_obj_add_strcpy(document, item, "type", event_text(event->type)) ||
!yyjson_mut_obj_add_strcpy(document, item, "start_at", event->start_at) ||
!(event->end_at[0] == '\0'
? yyjson_mut_obj_add_null(document, item, "end_at")
: yyjson_mut_obj_add_strcpy(document, item, "end_at", event->end_at)) ||
!yyjson_mut_arr_add_val(events, item)) {
return false;
}
}
return yyjson_mut_obj_add_val(document, object, "events", events) &&
yyjson_mut_arr_add_val(items, object);
}
static TrainlogStatus list_visitor(void *opaque, const TrainlogSleepDiaryEntry *entry) {
SleepListContext *context = opaque;
size_t index;
if (!add_entry(context->document, context->items, entry)) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
++context->count;
if (entry->sleep_quality != TRAINLOG_SLEEP_QUALITY_UNSET) {
++context->quality[0][entry->sleep_quality - 1];
}
if (entry->wake_quality != TRAINLOG_SLEEP_QUALITY_UNSET) {
++context->quality[1][entry->wake_quality - 1];
}
if (entry->day_form != TRAINLOG_SLEEP_QUALITY_UNSET) {
++context->quality[2][entry->day_form - 1];
}
for (index = 0U; index < entry->event_count; ++index) {
const TrainlogSleepEvent *event = &entry->events[index];
int64_t duration;
int64_t minute;
if (event->type == TRAINLOG_SLEEP_EVENT_LONG_AWAKE) {
++context->long_awake_count;
}
if (event->type == TRAINLOG_SLEEP_EVENT_NAP) {
++context->nap_count;
}
if (event->type == TRAINLOG_SLEEP_EVENT_DAYTIME_SLEEPINESS) {
++context->sleepiness_count;
}
if (event_duration(event, &duration)) {
if (event->type == TRAINLOG_SLEEP_EVENT_SLEEP) {
context->sleep_seconds += duration;
}
if (event->type == TRAINLOG_SLEEP_EVENT_LONG_AWAKE) {
context->long_awake_seconds += duration;
}
if (event->type == TRAINLOG_SLEEP_EVENT_NAP) {
context->nap_seconds += duration;
}
}
if (event->type == TRAINLOG_SLEEP_EVENT_BED_TIME &&
diary_minute(event->start_at, &minute)) {
context->bed_minute_total += minute;
++context->bed_count;
}
if (event->type == TRAINLOG_SLEEP_EVENT_FINAL_GET_UP &&
diary_minute(event->start_at, &minute)) {
context->get_up_minute_total += minute;
++context->get_up_count;
}
}
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus
write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size) {
yyjson_write_err error;
char *json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, output_size, &error);
yyjson_mut_doc_free(document);
if (json == NULL || *output_size > TRAINLOG_WEB_SLEEP_BYTES_MAX) {
free(json);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*output_json = json;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_sleep_list_json(TrainlogDatabase *database,
const char *start_date,
const char *end_date,
size_t limit,
char **output_json,
size_t *output_size) {
static const char *const quality_names[] = {"TB", "B", "Moy", "M", "TM"};
yyjson_mut_doc *document;
yyjson_mut_val *root;
yyjson_mut_val *summary;
yyjson_mut_val *distribution;
SleepListContext context = {0};
TrainlogStatus status;
size_t category, quality;
if (database == NULL || output_json == NULL || output_size == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
context.items = document == NULL ? NULL : yyjson_mut_arr(document);
summary = document == NULL ? NULL : yyjson_mut_obj(document);
distribution = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL || context.items == NULL || summary == NULL ||
distribution == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
context.document = document;
yyjson_mut_doc_set_root(document, root);
status =
trainlog_sleep_diary_list(database, start_date, end_date, limit, list_visitor, &context);
if (status != TRAINLOG_STATUS_OK ||
!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_uint(document, summary, "nights", context.count) ||
!yyjson_mut_obj_add_uint(document, summary, "long_awake_count", context.long_awake_count) ||
!yyjson_mut_obj_add_uint(document, summary, "nap_count", context.nap_count) ||
!yyjson_mut_obj_add_uint(document, summary, "sleepiness_count", context.sleepiness_count) ||
!yyjson_mut_obj_add_sint(
document, summary, "sleep_duration_seconds", context.sleep_seconds) ||
!yyjson_mut_obj_add_sint(
document, summary, "long_awake_duration_seconds", context.long_awake_seconds) ||
!yyjson_mut_obj_add_sint(document, summary, "nap_duration_seconds", context.nap_seconds) ||
!(context.bed_count == 0U
? yyjson_mut_obj_add_null(document, summary, "average_bed_minute")
: yyjson_mut_obj_add_sint(document,
summary,
"average_bed_minute",
context.bed_minute_total / (int64_t)context.bed_count)) ||
!(context.get_up_count == 0U
? yyjson_mut_obj_add_null(document, summary, "average_get_up_minute")
: yyjson_mut_obj_add_sint(document,
summary,
"average_get_up_minute",
context.get_up_minute_total /
(int64_t)context.get_up_count))) {
yyjson_mut_doc_free(document);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_SYSTEM_ERROR : status;
}
for (category = 0U; category < 3U; ++category) {
yyjson_mut_val *values = yyjson_mut_obj(document);
const char *key = category == 0U ? "sleep_quality"
: category == 1U ? "wake_quality"
: "day_form";
if (values == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
for (quality = 0U; quality < 5U; ++quality) {
if (!yyjson_mut_obj_add_uint(
document, values, quality_names[quality], context.quality[category][quality])) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
if (!yyjson_mut_obj_add_val(document, distribution, key, values)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
}
if (!yyjson_mut_obj_add_val(document, summary, "quality_distribution", distribution) ||
!yyjson_mut_obj_add_val(document, root, "summary", summary) ||
!yyjson_mut_obj_add_val(document, root, "entries", context.items)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return write_document(document, output_json, output_size);
}
static bool
copy_string(yyjson_val *object, const char *key, char *output, size_t capacity, bool nullable) {
yyjson_val *value = yyjson_obj_get(object, key);
size_t length;
if (nullable && yyjson_is_null(value)) {
output[0] = '\0';
return true;
}
if (!yyjson_is_str(value)) {
return false;
}
length = yyjson_get_len(value);
if (length >= capacity) {
return false;
}
memcpy(output, yyjson_get_str(value), length + 1U);
return true;
}
static bool parse_entry(const char *body,
size_t body_size,
TrainlogSleepDiaryEntry *entry,
char expected_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY]) {
yyjson_read_err error;
yyjson_doc *document =
yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error);
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
yyjson_val *events;
size_t index, count;
bool valid =
root != NULL && yyjson_is_obj(root) &&
copy_string(root, "entry_id", entry->entry_id, sizeof(entry->entry_id), true) &&
copy_string(root,
"expected_revision",
expected_revision,
TRAINLOG_SLEEP_REVISION_ID_CAPACITY,
true) &&
copy_string(root,
"night_start_date",
entry->night_start_date,
sizeof(entry->night_start_date),
false) &&
copy_string(
root, "night_end_date", entry->night_end_date, sizeof(entry->night_end_date), false) &&
copy_string(root, "created_at", entry->created_at, sizeof(entry->created_at), false) &&
copy_string(root, "updated_at", entry->updated_at, sizeof(entry->updated_at), false) &&
copy_string(root,
"treatment_and_notes",
entry->treatment_and_notes,
sizeof(entry->treatment_and_notes),
false) &&
quality_value(yyjson_obj_get(root, "sleep_quality"), &entry->sleep_quality) &&
quality_value(yyjson_obj_get(root, "wake_quality"), &entry->wake_quality) &&
quality_value(yyjson_obj_get(root, "day_form"), &entry->day_form);
events = valid ? yyjson_obj_get(root, "events") : NULL;
count = yyjson_is_arr(events) ? yyjson_arr_size(events) : TRAINLOG_SLEEP_EVENTS_MAX + 1U;
if (count > TRAINLOG_SLEEP_EVENTS_MAX) {
valid = false;
}
for (index = 0U; valid && index < count; ++index) {
yyjson_val *value = yyjson_arr_get(events, index);
TrainlogSleepEvent *event = &entry->events[index];
valid = yyjson_is_obj(value) &&
copy_string(value, "event_id", event->event_id, sizeof(event->event_id), true) &&
event_value(yyjson_obj_get(value, "type"), &event->type) &&
copy_string(value, "start_at", event->start_at, sizeof(event->start_at), false) &&
copy_string(value, "end_at", event->end_at, sizeof(event->end_at), true);
if (valid && event->event_id[0] == '\0') {
valid = trainlog_id_generate("sle", event->event_id, sizeof(event->event_id)) ==
TRAINLOG_STATUS_OK;
}
}
entry->event_count = count;
yyjson_doc_free(document);
return valid;
}
TrainlogStatus trainlog_web_sleep_save_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size) {
TrainlogSleepDiaryEntry entry = {0};
char expected[TRAINLOG_SLEEP_REVISION_ID_CAPACITY] = "";
TrainlogStatus status;
yyjson_mut_doc *document;
yyjson_mut_val *root;
if (database == NULL || body == NULL || body_size == 0U ||
body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX || output_json == NULL || output_size == NULL ||
!parse_entry(body, body_size, &entry, expected)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (expected[0] == '\0') {
status = trainlog_sleep_diary_create(database, &entry);
} else {
(void)snprintf(entry.revision_id, sizeof(entry.revision_id), "%s", expected);
status = trainlog_sleep_diary_update(database, expected, &entry);
}
if (status != TRAINLOG_STATUS_OK) {
return status;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL ||
!yyjson_mut_obj_add_strcpy(document, root, "entry_id", entry.entry_id) ||
!yyjson_mut_obj_add_strcpy(document, root, "revision_id", entry.revision_id)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
return write_document(document, output_json, output_size);
}
TrainlogStatus trainlog_web_sleep_delete_json(TrainlogDatabase *database,
const char *entry_id,
const char *expected_revision,
const char *deleted_at,
char **output_json,
size_t *output_size) {
char revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
TrainlogStatus status =
trainlog_sleep_diary_delete(database, entry_id, expected_revision, deleted_at, revision);
yyjson_mut_doc *document;
yyjson_mut_val *root;
if (status != TRAINLOG_STATUS_OK) {
return status;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL ||
!yyjson_mut_obj_add_strcpy(document, root, "entry_id", entry_id) ||
!yyjson_mut_obj_add_strcpy(document, root, "revision_id", revision) ||
!yyjson_mut_obj_add_bool(document, root, "deleted", true)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
return write_document(document, output_json, output_size);
}
TrainlogStatus trainlog_web_sleep_delete_request_json(TrainlogDatabase *database,
const char *body,
size_t body_size,
char **output_json,
size_t *output_size) {
yyjson_read_err error;
yyjson_doc *document;
yyjson_val *root;
const char *entry_id;
const char *revision;
const char *deleted_at;
TrainlogStatus status;
if (body == NULL || body_size == 0U || body_size > TRAINLOG_WEB_SLEEP_BYTES_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
document = yyjson_read_opts((char *)body, body_size, YYJSON_READ_NOFLAG, NULL, &error);
root = document == NULL ? NULL : yyjson_doc_get_root(document);
entry_id = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "entry_id"))
? yyjson_get_str(yyjson_obj_get(root, "entry_id"))
: NULL;
revision = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "expected_revision"))
? yyjson_get_str(yyjson_obj_get(root, "expected_revision"))
: NULL;
deleted_at = yyjson_is_obj(root) && yyjson_is_str(yyjson_obj_get(root, "deleted_at"))
? yyjson_get_str(yyjson_obj_get(root, "deleted_at"))
: NULL;
if (entry_id == NULL || revision == NULL || deleted_at == NULL || yyjson_obj_size(root) != 3U) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = trainlog_web_sleep_delete_json(
database, entry_id, revision, deleted_at, output_json, output_size);
yyjson_doc_free(document);
return status;
}

View file

@ -11,69 +11,125 @@
#include "trainlog/database.h"
#include "trainlog/id.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
return false; \
} \
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool test_database_open_and_schema(void) {
static bool test_database_open_and_schema(void)
{
TrainlogDatabase *database = NULL;
int schema_version = 0;
int foreign_keys = 0;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &schema_version) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_schema_version(database, &schema_version) ==
TRAINLOG_STATUS_OK
);
CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(trainlog_database_foreign_keys_enabled(database, &foreign_keys) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_foreign_keys_enabled(database, &foreign_keys) ==
TRAINLOG_STATUS_OK
);
CHECK(foreign_keys == 1);
trainlog_database_close(database);
return true;
}
static bool test_database_open_diagnostic(void) {
static bool test_database_open_diagnostic(void)
{
TrainlogDatabase *database = NULL;
char diagnostic[256];
/* CONTRACT: a launcher must retain the stable status code while showing
* the SQLite operation that blocked access to the user's real database. */
CHECK(trainlog_database_open_with_diagnostic("/", &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(
trainlog_database_open_with_diagnostic(
"/",
&database,
diagnostic,
sizeof(diagnostic)
) == TRAINLOG_STATUS_DATABASE_ERROR
);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "open database:") != NULL);
return true;
}
static bool test_generated_ids(void) {
static bool test_generated_ids(void)
{
char first[TRAINLOG_GENERATED_ID_CAPACITY];
char second[TRAINLOG_GENERATED_ID_CAPACITY];
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
char entry_id[TRAINLOG_GENERATED_ID_CAPACITY];
char v1_exact_capacity[2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U];
CHECK(trainlog_id_generate("ex", first, sizeof(first)) == TRAINLOG_STATUS_OK);
CHECK(trainlog_id_generate("ex", second, sizeof(second)) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_id_generate("ex", first, sizeof(first)) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_id_generate("ex", second, sizeof(second)) ==
TRAINLOG_STATUS_OK
);
CHECK(strncmp(first, "ex_", 3U) == 0);
CHECK(strlen(first) == 2U + 1U + TRAINLOG_UUID_TEXT_LENGTH);
CHECK(strcmp(first, second) != 0);
CHECK(first[3U + 14U] == '4');
/* ABI behavior: the new longer occurrence prefix must not invalidate the
* exact buffer size historically sufficient for two-character prefixes. */
CHECK(trainlog_id_generate("se", v1_exact_capacity, sizeof(v1_exact_capacity)) ==
TRAINLOG_STATUS_OK);
CHECK(
trainlog_id_generate(
"se",
v1_exact_capacity,
sizeof(v1_exact_capacity)
) == TRAINLOG_STATUS_OK
);
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
CHECK(trainlog_id_generate("sy", sync_id, sizeof(sync_id)) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_id_generate(
"sy",
sync_id,
sizeof(sync_id)
) == TRAINLOG_STATUS_OK
);
CHECK(strncmp(sync_id, "sy_", 3U) == 0);
CHECK(
strncmp(
sync_id,
"sy_",
3U
) == 0
);
CHECK(sync_id[3U + 14U] == '4');
CHECK(
sync_id[3U + 14U] ==
'4'
);
CHECK(trainlog_id_generate("sxe", entry_id, sizeof(entry_id)) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_id_generate(
"sxe",
entry_id,
sizeof(entry_id)
) == TRAINLOG_STATUS_OK
);
CHECK(strncmp(entry_id, "sxe_", 4U) == 0);
CHECK(strlen(entry_id) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
CHECK(entry_id[4U + 14U] == '4');
@ -81,15 +137,19 @@ static bool test_generated_ids(void) {
return true;
}
static bool seed_exercise(TrainlogDatabase *database) {
return trainlog_database_insert_exercise(database,
"ex_test",
"Presse à cuisses",
"presse à cuisses",
TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK;
static bool seed_exercise(TrainlogDatabase *database)
{
return trainlog_database_insert_exercise(
database,
"ex_test",
"Presse à cuisses",
"presse à cuisses",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK;
}
static bool test_session_insert(void) {
static bool test_session_insert(void)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput sets[3];
TrainlogSessionExerciseInput exercise;
@ -98,7 +158,10 @@ static bool test_session_insert(void) {
TrainlogPersistedExerciseDetail persisted_exercises[1];
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(seed_exercise(database));
(void)memset(sets, 0, sizeof(sets));
@ -112,7 +175,12 @@ static bool test_session_insert(void) {
sets[2].weight_kg = 0.0;
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "ex_test");
(void)snprintf(
exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_test"
);
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
exercise.rest_seconds = 60;
exercise.target_sets = 3;
@ -123,20 +191,50 @@ static bool test_session_insert(void) {
exercise.set_count = 3U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_test");
(void)snprintf(
session.started_at, sizeof(session.started_at), "%s", "2026-09-05T18:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-05T19:00:00+02:00");
session.session_id,
sizeof(session.session_id),
"%s",
"se_test"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-05T18:00:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-05T19:00:00+02:00"
);
session.exercises = &exercise;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_CONFLICT
);
CHECK(
trainlog_database_session_count(database, &count) ==
TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(trainlog_database_get_session_details(
database, "se_test", &persisted_session, persisted_exercises, 1U, &count) ==
TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_get_session_details(
database,
"se_test",
&persisted_session,
persisted_exercises,
1U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
/* Regression: local occurrences are `sxe`, never synchronization runs. */
CHECK(strncmp(persisted_exercises[0].entry_id, "sxe_", 4U) == 0);
@ -149,39 +247,67 @@ static bool test_session_insert(void) {
trainlog_database_free_session_details(persisted_exercises, count);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_negative_weight");
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_negative_weight");
sets[0].weight_kg = -0.5;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_nonfinite_weight");
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_nonfinite_weight");
sets[0].weight_kg = INFINITY;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_session_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(trainlog_database_session_count(database, &count) ==
TRAINLOG_STATUS_OK);
CHECK(count == 1U);
trainlog_database_close(database);
return true;
}
static bool test_body_weight_history(void) {
static bool test_body_weight_history(void)
{
TrainlogDatabase *database = NULL;
TrainlogBodyObservationInput observation;
TrainlogWeightPoint points[4];
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
(void)memset(&observation, 0, sizeof(observation));
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "%s", "bo_test");
(void)snprintf(observation.observed_at,
sizeof(observation.observed_at),
"%s",
"2026-09-05T07:00:00+02:00");
(void)snprintf(
observation.observation_id,
sizeof(observation.observation_id),
"%s",
"bo_test"
);
(void)snprintf(
observation.observed_at,
sizeof(observation.observed_at),
"%s",
"2026-09-05T07:00:00+02:00"
);
observation.has_body_weight = true;
observation.body_weight_kg = 82.4;
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_insert_body_observation(
database,
&observation
) == TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_list_weight_points(database, points, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_list_weight_points(
database,
points,
4U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(points[0].body_weight_kg > 82.39);
CHECK(points[0].body_weight_kg < 82.41);
@ -190,15 +316,22 @@ static bool test_body_weight_history(void) {
return true;
}
static bool test_transaction_rollback(void) {
static bool test_transaction_rollback(void)
{
TrainlogDatabase *database = NULL;
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK);
CHECK(seed_exercise(database));
CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_exercise_count(database, &count) ==
TRAINLOG_STATUS_OK
);
/* Schema v13 seeds the five frozen supplied machine identities. The
* rolled-back custom row must not change that baseline. */
CHECK(count == 5U);
@ -207,7 +340,8 @@ static bool test_transaction_rollback(void) {
return true;
}
static bool test_exercise_merge_aliases_and_conflicts(void) {
static bool test_exercise_merge_aliases_and_conflicts(void)
{
TrainlogDatabase *database = NULL;
TrainlogExerciseBodyZone zones[4];
const char *source_secondary[] = {"shoulders"};
@ -216,53 +350,50 @@ static bool test_exercise_merge_aliases_and_conflicts(void) {
TrainlogExerciseMergePreview preview;
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_source", "Curl A", "curl a", TRAINLOG_TRACKING_REPS) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_target", "Curl B", "curl b", TRAINLOG_TRACKING_REPS) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_final", "Curl C", "curl c", TRAINLOG_TRACKING_REPS) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(
database, "ex_duration", "Hold", "hold", TRAINLOG_TRACKING_DURATION) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(
database, "ex_source", "arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(
database, "ex_target", "arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source", &preview) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_source", "Curl A",
"curl a", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_target", "Curl B",
"curl b", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_final", "Curl C",
"curl c", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise(database, "ex_duration", "Hold",
"hold", TRAINLOG_TRACKING_DURATION) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_source",
"arms", source_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database, "ex_target",
"arms", target_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_preview_exercise_merge(database, "ex_source",
&preview) == TRAINLOG_STATUS_OK);
CHECK(preview.occurrences == 0U && preview.performed_sets == 0U &&
preview.continuous_activities == 0U && preview.max_results == 0U &&
preview.associated_equipment == 0U && preview.body_zones == 2U);
preview.continuous_activities == 0U && preview.max_results == 0U &&
preview.associated_equipment == 0U && preview.body_zones == 2U);
CHECK(trainlog_database_merge_exercises(database, "ex_source", "ex_target") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_target") == 0);
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones, 4U, &count) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_list_exercise_body_zones(database, "ex_target", zones,
4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 3U);
CHECK(strcmp(zones[0].zone_id, "arms") == 0 && zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
CHECK(strcmp(zones[0].zone_id, "arms") == 0 &&
zones[0].role == TRAINLOG_BODY_ZONE_PRIMARY);
/* A second merge must collapse every old source directly to the newest
* canonical ID; resolution never depends on an unbounded alias walk. */
CHECK(trainlog_database_merge_exercises(database, "ex_target", "ex_final") ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_source", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_source", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_target", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_target", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_merge_exercises(database, "ex_final", "ex_duration") ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_resolve_exercise_id(
database, "ex_final", canonical, sizeof(canonical)) == TRAINLOG_STATUS_OK);
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_database_resolve_exercise_id(database, "ex_final", canonical,
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 7U);
@ -275,7 +406,8 @@ struct TestCase {
bool (*function)(void);
};
int main(void) {
int main(void)
{
static const struct TestCase tests[] = {
{"database_open_and_schema", test_database_open_and_schema},
{"database_open_diagnostic", test_database_open_diagnostic},

View file

@ -432,12 +432,12 @@ static int run_test(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(version == 28);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw, "PRAGMA user_version") == 28);
CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),

View file

@ -119,7 +119,7 @@ static int run_test(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(version == 28);
trainlog_database_close(production);
production = NULL;

View file

@ -1,73 +0,0 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "trainlog/database.h"
#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;
int value = -1;
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
value = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return value;
}
int main(void) {
char path[] = "/tmp/trainlog-schema-v29-XXXXXX";
TrainlogDatabase *production = NULL;
sqlite3 *raw = NULL;
int descriptor = mkstemp(path);
int result = EXIT_FAILURE;
CHECK(descriptor >= 0 && close(descriptor) == 0);
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"DROP TABLE sleep_diary_events;DROP TABLE sleep_diary_revisions;"
"DROP TABLE sleep_diary_entries;PRAGMA user_version=28;",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'sleep_diary_%'") == 5);
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(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
result = EXIT_SUCCESS;
cleanup:
if (production != NULL) {
trainlog_database_close(production);
}
if (raw != NULL) {
(void)sqlite3_close(raw);
}
(void)unlink(path);
if (result == EXIT_SUCCESS) {
puts("PASS schema v28 to v29 migration and reopen");
}
return result;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v29: SQLite rc=",
strlen("migrate database to schema v29: SQLite rc=")) == 0);
"migrate database to schema v28: SQLite rc=",
strlen("migrate database to schema v28: 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=30;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=29;", 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 30 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 29 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v28") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -1,125 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "trainlog/database.h"
#include "trainlog/id.h"
#include "trainlog/sleep_diary.h"
#include "trainlog/web_sleep.h"
#define CHECK(value) \
do { \
if (!(value)) { \
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
return 1; \
} \
} while (0)
static TrainlogSleepDiaryEntry sample(void) {
TrainlogSleepDiaryEntry entry = {0};
(void)snprintf(entry.night_start_date, sizeof(entry.night_start_date), "2026-10-24");
(void)snprintf(entry.night_end_date, sizeof(entry.night_end_date), "2026-10-25");
(void)snprintf(entry.created_at, sizeof(entry.created_at), "2026-10-25T08:10:00+01:00");
(void)snprintf(entry.updated_at, sizeof(entry.updated_at), "%s", entry.created_at);
entry.sleep_quality = TRAINLOG_SLEEP_QUALITY_B;
entry.wake_quality = TRAINLOG_SLEEP_QUALITY_MOY;
(void)snprintf(
entry.treatment_and_notes, sizeof(entry.treatment_and_notes), "Synthetic fixture");
entry.event_count = 4U;
(void)trainlog_id_generate("sle", entry.events[0].event_id, sizeof(entry.events[0].event_id));
entry.events[0].type = TRAINLOG_SLEEP_EVENT_BED_TIME;
(void)snprintf(
entry.events[0].start_at, sizeof(entry.events[0].start_at), "2026-10-24T23:30:00+02:00");
(void)trainlog_id_generate("sle", entry.events[1].event_id, sizeof(entry.events[1].event_id));
entry.events[1].type = TRAINLOG_SLEEP_EVENT_SLEEP;
(void)snprintf(
entry.events[1].start_at, sizeof(entry.events[1].start_at), "2026-10-25T00:30:00+02:00");
(void)snprintf(
entry.events[1].end_at, sizeof(entry.events[1].end_at), "2026-10-25T02:30:00+02:00");
(void)trainlog_id_generate("sle", entry.events[2].event_id, sizeof(entry.events[2].event_id));
entry.events[2].type = TRAINLOG_SLEEP_EVENT_LONG_AWAKE;
(void)snprintf(
entry.events[2].start_at, sizeof(entry.events[2].start_at), "2026-10-25T02:30:00+02:00");
(void)snprintf(
entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:30:00+01:00");
(void)trainlog_id_generate("sle", entry.events[3].event_id, sizeof(entry.events[3].event_id));
entry.events[3].type = TRAINLOG_SLEEP_EVENT_FINAL_GET_UP;
(void)snprintf(
entry.events[3].start_at, sizeof(entry.events[3].start_at), "2026-10-25T08:00:00+01:00");
return entry;
}
static TrainlogStatus count_visitor(void *context, const TrainlogSleepDiaryEntry *entry) {
size_t *count = context;
if (entry == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
++*count;
return TRAINLOG_STATUS_OK;
}
int main(void) {
TrainlogDatabase *database = NULL;
TrainlogSleepDiaryEntry entry = sample();
TrainlogSleepDiaryEntry loaded;
TrainlogSleepDiaryEntry stale;
TrainlogSleepDiaryEntry second;
char *json = NULL;
size_t json_size = 0U;
char first_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
char deleted_revision[TRAINLOG_SLEEP_REVISION_ID_CAPACITY];
size_t count = 0U;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_sleep_diary_validate(&entry));
CHECK(trainlog_sleep_diary_create(database, &entry) == TRAINLOG_STATUS_OK);
second = sample();
(void)snprintf(second.night_start_date, sizeof(second.night_start_date), "2026-10-25");
(void)snprintf(second.night_end_date, sizeof(second.night_end_date), "2026-10-26");
(void)snprintf(
second.events[0].start_at, sizeof(second.events[0].start_at), "2026-10-26T00:30:00+01:00");
CHECK(trainlog_sleep_diary_create(database, &second) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_sleep_list_json(database, NULL, NULL, 7U, &json, &json_size) ==
TRAINLOG_STATUS_OK);
CHECK(json_size > 0U && strstr(json, "\"average_bed_minute\":360") != NULL);
free(json);
json = NULL;
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) == TRAINLOG_STATUS_OK);
CHECK(loaded.event_count == 4U && loaded.sleep_quality == TRAINLOG_SLEEP_QUALITY_B);
(void)snprintf(first_revision, sizeof(first_revision), "%s", loaded.revision_id);
stale = loaded;
loaded.day_form = TRAINLOG_SLEEP_QUALITY_TB;
(void)snprintf(loaded.updated_at, sizeof(loaded.updated_at), "2026-10-25T19:00:00+01:00");
CHECK(trainlog_sleep_diary_update(database, first_revision, &loaded) == TRAINLOG_STATUS_OK);
stale.day_form = TRAINLOG_SLEEP_QUALITY_TM;
(void)snprintf(stale.updated_at, sizeof(stale.updated_at), "2026-10-25T20:00:00+01:00");
CHECK(trainlog_sleep_diary_update(database, first_revision, &stale) ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_sleep_diary_list(
database, "2026-10-24", "2026-10-24", 7U, count_visitor, &count) ==
TRAINLOG_STATUS_OK &&
count == 1U);
CHECK(trainlog_sleep_diary_delete(database,
entry.entry_id,
loaded.revision_id,
"2026-10-26T09:00:00+01:00",
deleted_revision) == TRAINLOG_STATUS_OK);
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, false, &loaded) ==
TRAINLOG_STATUS_NOT_FOUND);
CHECK(trainlog_sleep_diary_get(database, entry.entry_id, true, &loaded) == TRAINLOG_STATUS_OK &&
loaded.deleted);
CHECK(trainlog_sleep_diary_delete(database,
entry.entry_id,
first_revision,
"2026-10-26T10:00:00+01:00",
deleted_revision) != TRAINLOG_STATUS_OK);
/* Same local clock across the DST fallback is a positive absolute interval. */
entry = sample();
(void)snprintf(
entry.events[2].end_at, sizeof(entry.events[2].end_at), "2026-10-25T02:15:00+02:00");
CHECK(!trainlog_sleep_diary_validate(&entry));
trainlog_database_close(database);
puts("PASS sleep diary domain");
return 0;
}

View file

@ -219,7 +219,7 @@ static bool import_archive_and_prepare(void) {
char *second_entry;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(scalar(database, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(database, "PRAGMA user_version") == 28);
CHECK(scalar(database,
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
"name IN('generation_id','acknowledged_at')") == 2);

View file

@ -3,8 +3,8 @@ export type AnalysisLanguage = 'fr' | 'en'
export const analysisMessages = {
fr: {
eyebrow: 'DONNÉES FACTUELLES', title: 'Analyse', loading: 'Chargement de l’analyse…', failed: 'Analyse momentanément indisponible.',
overview: 'Vue d’ensemble', exercises: 'Exercices', distribution: 'Répartition', measurements: 'Mensurations', sleep: 'Sommeil',
period: 'Période', periods: { '7d': '7 jours', '14d': '14 jours', '21d': '21 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' },
overview: 'Vue d’ensemble', exercises: 'Exercices', distribution: 'Répartition', measurements: 'Mensurations',
period: 'Période', periods: { '7d': '7 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' },
sessions: 'Séances', sets: 'Séries', duration: 'Durée', unavailable: 'Indisponible', noData: 'Aucune donnée sur cette période.',
activeProgram: 'Programme actif', noProgram: 'Aucun programme actif', nextSession: 'Prochaine séance',
exercise: 'Exercice', noExercise: 'Aucun exercice disponible', noExerciseData: 'Aucune donnée compatible pour cet exercice.',
@ -15,8 +15,8 @@ export const analysisMessages = {
},
en: {
eyebrow: 'FACTUAL DATA', title: 'Analysis', loading: 'Loading analysis…', failed: 'Analysis is temporarily unavailable.',
overview: 'Overview', exercises: 'Exercises', distribution: 'Distribution', measurements: 'Measurements', sleep: 'Sleep',
period: 'Period', periods: { '7d': '7 days', '14d': '14 days', '21d': '21 days', '30d': '30 days', '90d': '90 days', all: 'All' },
overview: 'Overview', exercises: 'Exercises', distribution: 'Distribution', measurements: 'Measurements',
period: 'Period', periods: { '7d': '7 days', '30d': '30 days', '90d': '90 days', all: 'All' },
sessions: 'Sessions', sets: 'Sets', duration: 'Duration', unavailable: 'Unavailable', noData: 'No data in this period.',
activeProgram: 'Active program', noProgram: 'No active program', nextSession: 'Next session',
exercise: 'Exercise', noExercise: 'No exercise available', noExerciseData: 'No compatible data for this exercise.',

View file

@ -1,9 +1,9 @@
export type AnalysisPeriod = '7d' | '14d' | '21d' | '30d' | '90d' | 'all'
export type AnalysisPeriod = '7d' | '30d' | '90d' | 'all'
export type MeasurementMetric = 'weight' | 'neck' | 'shoulders' | 'chest' | 'waist' | 'hips' |
'left_arm' | 'right_arm' | 'left_forearm' | 'right_forearm' |
'left_thigh' | 'right_thigh' | 'left_calf' | 'right_calf'
export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '14d', '21d', '30d', '90d', 'all']
export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '30d', '90d', 'all']
export const MEASUREMENT_METRICS: readonly MeasurementMetric[] = [
'weight', 'neck', 'shoulders', 'chest', 'waist', 'hips', 'left_arm', 'right_arm',
'left_forearm', 'right_forearm', 'left_thigh', 'right_thigh', 'left_calf', 'right_calf',

View file

@ -1,69 +0,0 @@
import { mutationCsrfToken } from './sync'
export type SleepQuality = 'TB' | 'B' | 'Moy' | 'M' | 'TM'
export type SleepEventType = 'bed_time' | 'final_get_up' | 'night_get_up' | 'sleep' |
'nap' | 'long_awake' | 'half_sleep' | 'daytime_sleepiness'
export interface SleepEvent { event_id: string; type: SleepEventType; start_at: string; end_at: string | null }
export interface SleepEntry {
entry_id: string; night_start_date: string; night_end_date: string
created_at: string; updated_at: string; revision_id: string
sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null
treatment_and_notes: string; events: SleepEvent[]
}
export interface SleepSnapshot {
api_version: 1; entries: SleepEntry[]
summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number
sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
average_bed_minute: number | null; average_get_up_minute: number | null }
}
export type SleepEntryInput = Omit<SleepEntry, 'revision_id'> & { expected_revision: string | null }
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const quality = (value: unknown): value is SleepQuality | null =>
value === null || ['TB', 'B', 'Moy', 'M', 'TM'].includes(String(value))
const eventType = (value: unknown): value is SleepEventType =>
['bed_time', 'final_get_up', 'night_get_up', 'sleep', 'nap', 'long_awake', 'half_sleep', 'daytime_sleepiness'].includes(String(value))
function validEntry(value: unknown): value is SleepEntry {
return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' &&
typeof value.night_end_date === 'string' && typeof value.created_at === 'string' &&
typeof value.updated_at === 'string' && typeof value.revision_id === 'string' &&
quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) &&
typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 &&
value.events.every((event) => object(event) && typeof event.event_id === 'string' &&
eventType(event.type) && typeof event.start_at === 'string' &&
(event.end_at === null || typeof event.end_at === 'string'))
}
export async function fetchSleepDiary(startDate?: string, endDate?: string,
signal?: AbortSignal): Promise<SleepSnapshot> {
const query = new URLSearchParams({ limit: '3660' })
if (startDate) query.set('start_date', startDate)
if (endDate) query.set('end_date', endDate)
const response = await fetch(`/api/v1/sleep-diary?${query}`, { headers: { Accept: 'application/json' }, signal })
const value: unknown = await response.json()
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.entries) ||
!value.entries.every(validEntry) || !object(value.summary)) throw new TypeError('sleep_diary_invalid')
return value as unknown as SleepSnapshot
}
async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ entry_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-diary', {
method,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': csrf },
body: JSON.stringify(body),
})
const value: unknown = await response.json()
if (!response.ok || !object(value) || typeof value.entry_id !== 'string' || typeof value.revision_id !== 'string') {
throw new Error(object(value) && typeof value.error === 'string' ? value.error : 'sleep_diary_mutation_failed')
}
return value as { entry_id: string; revision_id: string }
}
export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry)
export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', {
entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: new Date().toISOString(),
})

View file

@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildSleepDiaryPdf } from './sleepDiaryPdf'
import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary'
const entry = (index: number): SleepEntry => ({
entry_id: `sl_${index}`, night_start_date: `2026-09-${String(index + 1).padStart(2, '0')}`,
night_end_date: `2026-09-${String(index + 2).padStart(2, '0')}`, created_at: '2026-09-01T18:00:00+02:00',
updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B',
wake_quality: 'Moy', day_form: 'TB', treatment_and_notes: 'Synthetic observation', events: [
{ event_id: `sle_${index}`, type: 'sleep', start_at: '2026-09-01T23:30:00+02:00', end_at: '2026-09-02T06:30:00+02:00' },
],
})
const snapshot = (count: number): SleepSnapshot => ({ api_version: 1,
entries: Array.from({ length: count }, (_, index) => entry(index)), summary: { nights: count,
long_awake_count: 1, nap_count: 1, sleepiness_count: 1, sleep_duration_seconds: 25_200,
long_awake_duration_seconds: 1_800, nap_duration_seconds: 1_200, average_bed_minute: 330,
average_get_up_minute: 750 } })
describe('sleep diary PDF', () => {
it('builds a vector PDF without screen capture', async () => {
const bytes = new Uint8Array(await buildSleepDiaryPdf(snapshot(7), 'fr').arrayBuffer())
expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe('%PDF-1.4')
expect(new TextDecoder().decode(bytes)).toContain('AGENDA TRAINLOG')
})
it('paginates fourteen, twenty-one and thirty days', async () => {
for (const count of [14, 21, 30]) {
const text = await buildSleepDiaryPdf(snapshot(count), 'en').text()
expect((text.match(/\/Type \/Page /g) ?? []).length).toBe(Math.ceil(count / 9))
expect(text).toContain('OBSERVATIONS')
}
})
})

View file

@ -1,58 +0,0 @@
import type { SleepEntry, SleepEventType, SleepSnapshot } from '../api/sleepDiary'
const ascii = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\x20-\x7e]/g, '?')
const text = (x: number, y: number, size: number, value: string) => `0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${ascii(value).replace(/([\\()])/g, '\\$1')}) Tj ET\n`
const minute = (iso: string, night: string) => Math.max(0, Math.min(1440,
(new Date(iso).getTime() - new Date(`${night}T18:00:00`).getTime()) / 60000))
const labels: Record<SleepEventType, string> = { bed_time: 'v', final_get_up: '^', night_get_up: '^',
sleep: 'SLEEP', nap: 'NAP', long_awake: 'AWAKE', half_sleep: 'HALF', daytime_sleepiness: 'S' }
function row(stream: string[], entry: SleepEntry, y: number) {
const timelineX = 112; const width = 530
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`)
stream.push(text(33, y - 16, 7, `${entry.night_start_date} > ${entry.night_end_date}`))
for (let hour = 0; hour <= 24; hour++) { const x = timelineX + hour / 24 * width; stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`) }
entry.events.forEach((event) => {
const x = timelineX + minute(event.start_at, entry.night_start_date) / 1440 * width
if (event.end_at) {
const eventWidth = Math.max(2, timelineX + minute(event.end_at, entry.night_start_date) / 1440 * width - x)
const shade = event.type === 'sleep' ? 0.75 : event.type === 'half_sleep' ? 0.87 : 0.93
stream.push(`${shade} g ${x} ${y - 29} ${eventWidth} 18 re f 0 G ${x} ${y - 29} ${eventWidth} 18 re S\n`)
if (eventWidth > 24) stream.push(text(x + 2, y - 23, 5, labels[event.type]))
} else stream.push(text(x, y - 25, 8, labels[event.type]))
})
stream.push(text(648, y - 16, 7, entry.sleep_quality ?? '-')); stream.push(text(680, y - 16, 7, entry.wake_quality ?? '-'))
stream.push(text(712, y - 16, 7, entry.day_form ?? '-')); stream.push(text(744, y - 13, 5, entry.treatment_and_notes.slice(0, 24)))
stream.push(text(744, y - 22, 5, entry.treatment_and_notes.slice(24, 48)))
}
function page(snapshot: SleepSnapshot, entries: SleepEntry[], index: number, count: number, language: 'fr' | 'en') {
const stream = ['0 G 0 g\n', text(30, 565, 14, language === 'fr' ? 'AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL' : 'TRAINLOG SLEEP AND ALERTNESS DIARY'), text(740, 565, 7, `${index + 1}/${count}`), text(30, 548, 7, 'DATE')]
for (let hour = 0; hour <= 24; hour++) stream.push(text(112 + hour / 24 * 530, 548, 5, String((18 + hour) % 24)))
stream.push(text(646, 548, 5, 'SLEEP'), text(678, 548, 5, 'WAKE'), text(710, 548, 5, 'DAY'), text(744, 548, 5, 'NOTES'))
entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40))
const y = 520 - entries.length * 40
stream.push(text(30, y, 9, 'OBSERVATIONS'), text(30, y - 14, 6, `${snapshot.summary.nights} nights; sleep ${Math.round(snapshot.summary.sleep_duration_seconds / 60)} min; long awake ${snapshot.summary.long_awake_count}; naps ${snapshot.summary.nap_count}; sleepiness ${snapshot.summary.sleepiness_count}`))
entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 27 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`)))
stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.'))
return stream.join('')
}
/** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */
export function buildSleepDiaryPdf(snapshot: SleepSnapshot, language: 'fr' | 'en'): Blob {
const groups = Array.from({ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) }, (_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9))
const objects = ['<< /Type /Catalog /Pages 2 0 R >>', `<< /Type /Pages /Kids [${groups.map((_, index) => `${4 + index * 2} 0 R`).join(' ')}] /Count ${groups.length} >>`, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>']
groups.forEach((entries, index) => { const content = page(snapshot, entries, index, groups.length, language); objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + index * 2} 0 R >>`, `<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`) })
let output = '%PDF-1.4\n%Trainlog\n'; const offsets: number[] = []
objects.forEach((object, index) => { offsets.push(new TextEncoder().encode(output).length); output += `${index + 1} 0 obj\n${object}\nendobj\n` })
const xref = new TextEncoder().encode(output).length
output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((value) => `${String(value).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`
return new Blob([new TextEncoder().encode(output)], { type: 'application/pdf' })
}
export function presentSleepDiaryPdf(blob: Blob, download: boolean) {
const url = URL.createObjectURL(blob)
if (download) { const link = document.createElement('a'); link.href = url; link.download = 'trainlog-sleep-diary.pdf'; link.click() }
else window.open(url, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
}

View file

@ -12,9 +12,8 @@ import {
import { analysisMessages, type AnalysisLanguage } from "../analysis/messages";
import { BodyZoneFigure } from "../dashboard/BodyZoneFigure";
import { formatDuration } from "../dashboard/dashboardFormat";
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements" | "sleep";
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements";
type ExerciseMetric =
"duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
interface ChartPoint {
@ -34,7 +33,6 @@ const sections: readonly AnalysisSection[] = [
"exercise",
"body-zones",
"measurements",
"sleep",
];
const exerciseIdPattern =
/^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@ -607,9 +605,7 @@ export function AnalysisPage() {
? t.exercises
: id === "body-zones"
? t.distribution
: id === "measurements"
? t.measurements
: t.sleep}
: t.measurements}
</button>
))}
</div>
@ -874,9 +870,6 @@ export function AnalysisPage() {
)}
</article>
)}
{section === "sleep" && (
<SleepDiaryWorkspace period={period} language={language} />
)}
</>
)}
</section>

View file

@ -1,82 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { deleteSleepEntry, fetchSleepDiary, saveSleepEntry, type SleepEntry, type SleepEntryInput,
type SleepEvent, type SleepEventType, type SleepQuality } from '../api/sleepDiary'
import type { AnalysisPeriod } from '../api/analysis'
import { buildSleepDiaryPdf, presentSleepDiaryPdf } from '../report/sleepDiaryPdf'
type Language = 'fr' | 'en'
const q: readonly SleepQuality[] = ['TB', 'B', 'Moy', 'M', 'TM']
const pointTypes: readonly SleepEventType[] = ['bed_time', 'final_get_up', 'night_get_up', 'daytime_sleepiness']
const copy = {
fr: { agenda: 'Agenda', edit: 'Saisie et édition', morning: 'Matin', day: 'Journée / Soir', night: 'Nuit du',
bed_time: 'Mise au lit', final_get_up: 'Lever', night_get_up: 'Lever nocturne', sleep: 'Sommeil', nap: 'Sieste',
long_awake: 'Long réveil', half_sleep: 'Demi-sommeil', daytime_sleepiness: 'Somnolence', sleepQuality: 'Qualité du sommeil',
wakeQuality: 'Qualité du réveil', dayForm: 'Forme de la journée', notes: 'Traitement et remarques', add: 'Ajouter',
save: 'Enregistrer', remove: 'Supprimer', observations: 'Observations', summary: 'Synthèse factuelle', empty: 'Aucune nuit enregistrée.',
preview: 'Prévisualiser', export: 'Exporter PDF', qualities: ['Très bon', 'Bon', 'Moyen', 'Mauvais', 'Très mauvais'] },
en: { agenda: 'Diary', edit: 'Entry editor', morning: 'Morning', day: 'Day / Evening', night: 'Night from',
bed_time: 'Bedtime', final_get_up: 'Final get-up', night_get_up: 'Night get-up', sleep: 'Sleep', nap: 'Nap',
long_awake: 'Long awakening', half_sleep: 'Half-sleep', daytime_sleepiness: 'Sleepiness', sleepQuality: 'Sleep quality',
wakeQuality: 'Wake quality', dayForm: 'Day form', notes: 'Treatment and notes', add: 'Add', save: 'Save', remove: 'Delete',
observations: 'Observations', summary: 'Factual summary', empty: 'No recorded nights.', preview: 'Preview', export: 'Export PDF',
qualities: ['Very good', 'Good', 'Average', 'Bad', 'Very bad'] },
} as const
const localInput = (iso: string) => iso ? iso.slice(0, 16) : ''
const absolute = (value: string) => value ? new Date(value).toISOString() : ''
const offset = (iso: string, startDate: string) => {
const start = new Date(`${startDate}T18:00:00`)
return Math.max(0, Math.min(100, ((new Date(iso).getTime() - start.getTime()) / 86_400_000) * 100))
}
const blank = (): SleepEntryInput => {
const now = new Date(); const start = new Date(now); if (now.getHours() < 18) start.setDate(start.getDate() - 1)
const end = new Date(start); end.setDate(end.getDate() + 1)
const date = (value: Date) => value.toLocaleDateString('en-CA')
return { entry_id: '', expected_revision: null, night_start_date: date(start), night_end_date: date(end),
created_at: now.toISOString(), updated_at: now.toISOString(), sleep_quality: null, wake_quality: null,
day_form: null, treatment_and_notes: '', events: [] }
}
function QualityPicker({ label, value, onChange, language }: { label: string; value: SleepQuality | null;
onChange: (value: SleepQuality | null) => void; language: Language }) {
return <fieldset className="sleep-quality"><legend>{label}</legend>{q.map((item, index) =>
<button type="button" key={item} aria-pressed={value === item} title={copy[language].qualities[index]}
onClick={() => onChange(value === item ? null : item)}>{item}</button>)}</fieldset>
}
export function SleepDiaryWorkspace({ period, language }: { period: AnalysisPeriod; language: Language }) {
const t = copy[language]; const [snapshot, setSnapshot] = useState<Awaited<ReturnType<typeof fetchSleepDiary>> | null>(null); const entries = snapshot?.entries ?? []; const [draft, setDraft] = useState<SleepEntryInput>(blank)
const [eventType, setEventType] = useState<SleepEventType>('sleep'); const [pending, setPending] = useState(false); const [error, setError] = useState('')
const range = useMemo(() => { if (period === 'all') return {}; const days = Number(period.slice(0, -1)); const end = new Date(); const start = new Date(); start.setDate(end.getDate() - days + 1)
return { startDate: start.toLocaleDateString('en-CA'), endDate: end.toLocaleDateString('en-CA') } }, [period])
const reload = () => fetchSleepDiary(range.startDate, range.endDate).then(setSnapshot).catch(() => setError('sleep_diary_unavailable'))
useEffect(() => { void reload() }, [period])
const edit = (entry: SleepEntry) => setDraft({ ...entry, expected_revision: entry.revision_id, events: entry.events.map((event) => ({ ...event })) })
const change = <K extends keyof SleepEntryInput>(key: K, value: SleepEntryInput[K]) => setDraft((current) => ({ ...current, [key]: value }))
const addEvent = () => setDraft((current) => ({ ...current, events: [...current.events, { event_id: '', type: eventType,
start_at: new Date().toISOString(), end_at: pointTypes.includes(eventType) ? null : new Date(Date.now() + 3_600_000).toISOString() }] }))
const changeEvent = (index: number, patch: Partial<SleepEvent>) => setDraft((current) => ({ ...current,
events: current.events.map((event, currentIndex) => currentIndex === index ? { ...event, ...patch } : event) }))
const save = async () => { setPending(true); setError(''); try { await saveSleepEntry({ ...draft, updated_at: new Date().toISOString() }); setDraft(blank()); await reload() } catch (reason) { setError(reason instanceof Error ? reason.message : 'sleep_diary_mutation_failed') } finally { setPending(false) } }
return <div className="sleep-workspace">
<article className="analysis-panel analysis-wide"><div className="sleep-heading"><h2>{t.agenda}</h2><div><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), false)}>{t.preview}</button><button type="button" disabled={!snapshot} onClick={() => snapshot && presentSleepDiaryPdf(buildSleepDiaryPdf(snapshot, language), true)}>{t.export}</button></div></div>
{entries.length === 0 ? <p className="analysis-empty">{t.empty}</p> : <div className="sleep-agenda-scroll"><div className="sleep-agenda" role="table">
<div className="sleep-hours" aria-hidden="true">{Array.from({ length: 25 }, (_, index) => <span key={index}>{(18 + index) % 24}</span>)}</div>
{entries.map((entry) => <button type="button" className="sleep-row" key={entry.entry_id} onClick={() => edit(entry)}>
<strong>{t.night} {entry.night_start_date}</strong><span className="sleep-track">{entry.events.map((event) => {
const left = offset(event.start_at, entry.night_start_date); const width = event.end_at ? Math.max(1, offset(event.end_at, entry.night_start_date) - left) : 1
return <i key={event.event_id} className={`sleep-event sleep-${event.type}`} style={{ left: `${left}%`, width: `${width}%` }}
title={`${t[event.type]} ${event.start_at}${event.end_at ? ` – ${event.end_at}` : ''}`}>{event.type === 'bed_time' ? '↓' : event.type === 'final_get_up' || event.type === 'night_get_up' ? '↑' : event.type === 'daytime_sleepiness' ? 'S' : ''}</i>})}</span>
<span>{entry.sleep_quality ?? '—'} / {entry.wake_quality ?? '—'} / {entry.day_form ?? '—'}</span></button>)}</div></div>}
</article>
<article className="analysis-panel analysis-wide"><h2>{t.edit}</h2><div className="sleep-dates"><label>{t.night}<input type="date" value={draft.night_start_date} onChange={(e) => change('night_start_date', e.target.value)} /></label><label>→<input type="date" value={draft.night_end_date} onChange={(e) => change('night_end_date', e.target.value)} /></label></div>
<div className="sleep-editor-columns"><section><h3>{t.morning}</h3><QualityPicker label={t.sleepQuality} value={draft.sleep_quality} onChange={(value) => change('sleep_quality', value)} language={language}/><QualityPicker label={t.wakeQuality} value={draft.wake_quality} onChange={(value) => change('wake_quality', value)} language={language}/></section>
<section><h3>{t.day}</h3><QualityPicker label={t.dayForm} value={draft.day_form} onChange={(value) => change('day_form', value)} language={language}/></section></div>
<div className="sleep-event-add"><label>{t.add}<select value={eventType} onChange={(e) => setEventType(e.target.value as SleepEventType)}>{(['bed_time','sleep','long_awake','night_get_up','final_get_up','half_sleep','nap','daytime_sleepiness'] as const).map((type) => <option key={type} value={type}>{t[type]}</option>)}</select></label><button type="button" onClick={addEvent}>{t.add}</button></div>
<ul className="sleep-event-list">{draft.events.map((event, index) => <li key={`${event.event_id}-${index}`}><strong>{t[event.type]}</strong><label>{t[event.type]}<input type="datetime-local" value={localInput(event.start_at)} onChange={(e) => changeEvent(index, { start_at: absolute(e.target.value) })}/></label>{event.end_at !== null && <label>→<input type="datetime-local" value={localInput(event.end_at)} onChange={(e) => changeEvent(index, { end_at: absolute(e.target.value) })}/></label>}<button type="button" aria-label={`${t.remove} ${t[event.type]}`} onClick={() => change('events', draft.events.filter((_, item) => item !== index))}>×</button></li>)}</ul>
<label className="sleep-notes">{t.notes}<textarea value={draft.treatment_and_notes} maxLength={16384} onChange={(e) => change('treatment_and_notes', e.target.value)}/></label>
{error && <p role="alert" className="error-panel">{error}</p>}<div className="sleep-actions"><button type="button" disabled={pending} onClick={() => void save()}>{t.save}</button>{draft.expected_revision && <button type="button" disabled={pending} onClick={async () => { const entry = entries.find((item) => item.entry_id === draft.entry_id); if (entry) { await deleteSleepEntry(entry); setDraft(blank()); await reload() } }}>{t.remove}</button>}</div>
</article>
<div className="analysis-section-grid"><article className="analysis-panel"><h2>{t.summary}</h2><dl className="analysis-facts"><div><dt>{language === 'fr' ? 'Nuits enregistrées' : 'Recorded nights'}</dt><dd>{entries.length}</dd></div><div><dt>{language === 'fr' ? 'Sommeil déclaré' : 'Declared sleep'}</dt><dd>{Math.round((snapshot?.summary.sleep_duration_seconds ?? 0) / 60)} min</dd></div><div><dt>{language === 'fr' ? 'Longs réveils' : 'Long awakenings'}</dt><dd>{snapshot?.summary.long_awake_count ?? 0}</dd></div><div><dt>{language === 'fr' ? 'Siestes' : 'Naps'}</dt><dd>{snapshot?.summary.nap_count ?? 0}</dd></div></dl></article><article className="analysis-panel"><h2>{t.observations}</h2>{entries.filter((entry) => entry.treatment_and_notes).map((entry) => <p key={entry.entry_id}><strong>{entry.night_start_date}</strong> — {entry.treatment_and_notes}</p>)}</article></div>
</div>
}

View file

@ -600,32 +600,6 @@ h2 {
text-underline-offset: 0.2rem;
cursor: pointer;
}
.sleep-workspace { display: grid; gap: 1rem; }
.sleep-heading, .sleep-actions, .sleep-event-add, .sleep-dates { display: flex; gap: .75rem; align-items: center; justify-content: space-between; }
.sleep-heading > div { display: flex; gap: .5rem; }
.sleep-agenda-scroll { overflow-x: auto; max-width: 100%; }
.sleep-agenda { min-width: 900px; display: grid; gap: .25rem; }
.sleep-hours { display: grid; grid-template-columns: 13rem repeat(25, 1fr) 9rem; color: var(--text-muted); font-size: .72rem; }
.sleep-hours::before { content: ''; }
.sleep-row { display: grid; grid-template-columns: 13rem 1fr 9rem; align-items: center; gap: .5rem; min-height: 2.6rem; text-align: left; border-radius: var(--radius-md); }
.sleep-track { position: relative; height: 1.65rem; background: color-mix(in srgb, var(--surface-raised) 80%, transparent); border: 1px solid var(--border-subtle); }
.sleep-event { position: absolute; top: .15rem; bottom: .15rem; min-width: .3rem; overflow: visible; font-style: normal; text-align: center; color: var(--text-primary); }
.sleep-sleep, .sleep-nap { background: repeating-linear-gradient(135deg, var(--accent) 0 3px, transparent 3px 6px); }
.sleep-long_awake { border: 2px solid var(--warning); background: transparent; }
.sleep-half_sleep { background: repeating-linear-gradient(90deg, var(--lavender) 0 2px, transparent 2px 5px); }
.sleep-editor-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.sleep-quality { border: 0; padding: .25rem 0; display: flex; flex-wrap: wrap; gap: .35rem; }
.sleep-quality button[aria-pressed="true"] { border-color: var(--lavender); color: var(--lavender); background: var(--surface-raised); }
.sleep-event-list { display: grid; gap: .5rem; padding: 0; list-style: none; }
.sleep-event-list li { display: grid; grid-template-columns: 10rem 1fr 1fr auto; gap: .5rem; align-items: end; padding: .65rem; background: var(--surface-raised); border-radius: var(--radius-md); }
.sleep-event-list label, .sleep-notes { display: grid; gap: .25rem; }
.sleep-notes textarea { min-height: 6rem; resize: vertical; }
@media (max-width: 760px) {
.sleep-editor-columns { grid-template-columns: 1fr; }
.sleep-event-list li { grid-template-columns: 1fr; }
.sleep-heading { align-items: flex-start; flex-direction: column; }
}
.analysis-delta dd {
color: var(--lavender);
}