feat(android): add sleep diary capture

This commit is contained in:
fy59 2026-09-21 06:24:13 +02:00
parent 36a7e651ac
commit bf27e05e7a
21 changed files with 680 additions and 15 deletions

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..25
manifest.getInt("schema") in 17..26
) {
"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..25) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -42,6 +42,11 @@ 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
@ -466,6 +471,300 @@ 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." }
@ -511,6 +810,7 @@ class TrainlogRepository(
artifacts["feedback"] = buildTrainingFeedbackJson(true)
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts
}
@ -8754,7 +9054,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
25,
26,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -8804,6 +9104,7 @@ private class TrainlogDatabaseHelper(
createSessionPreparationWithdrawalTable(db)
createSyncedProgramTables(db)
createProgramExecutionProvenance(db)
createSleepDiaryTables(db)
seedEquipment(db)
}
@ -9009,6 +9310,14 @@ 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(
@ -9075,6 +9384,45 @@ 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

@ -0,0 +1,21 @@
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,6 +43,7 @@ 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,
)
@ -60,6 +61,7 @@ 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) {
@ -73,6 +75,7 @@ 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, SYNC, SETTINGS,
EQUIPMENT, SLEEP, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -35,6 +35,7 @@ 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 }
@ -45,6 +46,7 @@ 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

@ -0,0 +1,151 @@
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,6 +140,7 @@ 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,6 +8,26 @@
<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,6 +8,26 @@
<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(25, scalar(db, "PRAGMA user_version"))
assertEquals(26, 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(25, scalar(db, "PRAGMA user_version"))
assertEquals(26, 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(25, scalar(db, "PRAGMA user_version"))
assertEquals(26, 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(25, scalar(db, "PRAGMA user_version"))
assertEquals(26, 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

@ -0,0 +1,50 @@
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(25, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(26, 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(25, it.version) }
.use { assertEquals(26, 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("25", scalarText(name, "PRAGMA user_version"))
assertEquals("26", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -0,0 +1,49 @@
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(25, it.getInt(0))
assertEquals(26, 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(25, cursor.getInt(0))
assertEquals(26, 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(25, cursor.getInt(0))
assertEquals(26, 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(6, AppSection.entries.size)
assertEquals(7, 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)