Compare commits
5 commits
6948e0bbd5
...
1096b13939
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1096b13939 | ||
|
|
bf27e05e7a | ||
|
|
36a7e651ac | ||
|
|
30df3030e6 | ||
|
|
2565d0eafa |
86 changed files with 3025 additions and 311 deletions
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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 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. |
|
||||
| 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. |
|
||||
|
||||
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 v28 and Android SQLite schema v25;
|
||||
- desktop SQLite schema v29 and Android SQLite schema v26;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||
"program-executions" to
|
||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
|
||||
)
|
||||
private val SUPPORTED =
|
||||
(ANDROID_KINDS.values +
|
||||
|
|
@ -664,6 +665,7 @@ 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()
|
||||
}
|
||||
|
||||
|
|
@ -840,6 +842,10 @@ 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"]))
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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++ }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v28 |
|
||||
| Android SQLite | schema v25 |
|
||||
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
|
||||
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
|
||||
| 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,6 +129,7 @@ parallel implementation of its rules.
|
|||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||
| Sleep Diary V1 | 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) |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -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 v28, Android schema v25, Notcurses, direct `Documents/Trainlog`
|
||||
Desktop schema v29, Android schema v26, 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,6 +58,10 @@ 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
# 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
|
||||
|
|
|
|||
60
tests/test_sleep_diary_exchange.py
Normal file
60
tests/test_sleep_diary_exchange.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/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()
|
||||
|
|
@ -203,6 +203,8 @@ 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=[]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
raise CausalError(f"desktop schema v20-v28 required, found v{version}")
|
||||
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}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise LifecycleError(f"desktop schema v19-v28 required, found v{version}")
|
||||
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}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
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-v28 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v28 requis")
|
||||
raise ValueError("schema desktop v18-v29 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 29))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v28 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 29))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v28 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise ValueError("schema desktop v12-v28 requis")
|
||||
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")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -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, 29))
|
||||
supported_versions = tuple(range(11, 30))
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 29):
|
||||
if version not in range(8, 30):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 29)
|
||||
supported_versions = range(11, 30)
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
raise ValueError("desktop schema v26, v27 or v28 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29):
|
||||
raise ValueError("desktop schema v26, v27, v28 or v29 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26, v27 or v28 required")
|
||||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
deliveries = []
|
||||
rows = connection.execute(
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||
|
|
|
|||
|
|
@ -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, 29)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v28 requis")
|
||||
raise ValueError("schema desktop v15-v29 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()
|
||||
|
|
|
|||
|
|
@ -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, 29)
|
||||
supported_versions = range(18, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v28 requis")
|
||||
raise ImportFailure("schema desktop v18-v29 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -206,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, 29)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
|
|
|
|||
|
|
@ -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, 29)
|
||||
supported_versions = range(8, 30)
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 29)
|
||||
supported_versions = range(12, 30)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
|
|
|
|||
|
|
@ -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, 29)
|
||||
supported_versions = range(11, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v28 requis")
|
||||
raise ImportFailure("schema desktop v11-v29 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
fail("schema desktop v17-v28 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
fail("schema desktop v17-v29 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in range(5, 29):
|
||||
if version not in range(5, 30):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v28 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v29 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):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28")
|
||||
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 schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 29)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v28 requis")
|
||||
fail("schema desktop v15-v29 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"]):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
fail("desktop schema v27 or v28 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29):
|
||||
fail("desktop schema v27, v28 or v29 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
218
tools/sleep_diary_exchange.py
Normal file
218
tools/sleep_diary_exchange.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#!/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()
|
||||
|
|
@ -29,6 +29,7 @@ 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
|
||||
|
|
@ -75,6 +76,8 @@ 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),
|
||||
|
|
@ -206,9 +209,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)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24, v25, v26, v27 or v28 required")
|
||||
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -325,7 +328,8 @@ 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"):
|
||||
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py",
|
||||
"sleep_diary_exchange.py"):
|
||||
command.extend(extra); command.append(str(output))
|
||||
else:
|
||||
command.append(str(output)); command.extend(extra)
|
||||
|
|
@ -610,6 +614,8 @@ 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
|
||||
|
|
@ -651,6 +657,8 @@ 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)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
#include "trainlog/model.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 28
|
||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29
|
||||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
* 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.
|
||||
|
|
|
|||
100
tui/include/trainlog/sleep_diary.h
Normal file
100
tui/include/trainlog/sleep_diary.h
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* 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
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
|
||||
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
|
||||
|
|
|
|||
40
tui/include/trainlog/web_sleep.h
Normal file
40
tui/include/trainlog/web_sleep.h
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#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
|
||||
|
|
@ -48,6 +48,7 @@ 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',
|
||||
|
|
@ -77,6 +78,9 @@ 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',
|
||||
|
|
@ -219,6 +223,7 @@ 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',
|
||||
|
|
@ -229,6 +234,7 @@ 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',
|
||||
|
|
@ -801,6 +807,22 @@ 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',
|
||||
|
|
@ -875,6 +897,11 @@ 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'),
|
||||
|
|
|
|||
|
|
@ -1274,6 +1274,41 @@ 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. */
|
||||
|
|
@ -2063,7 +2098,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 == 26 || version == 27 || version == 28 || version == 29) {
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
} else {
|
||||
if (version == 1) {
|
||||
|
|
@ -2205,6 +2240,9 @@ 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);
|
||||
}
|
||||
|
|
@ -2224,7 +2262,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 v28" : "migrate database to schema v28",
|
||||
version == 0 ? "create schema v29" : "migrate database to schema v29",
|
||||
database->connection,
|
||||
SQLITE_ERROR);
|
||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ 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, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0 ||
|
||||
strcmp(prefix, "sl") == 0 || strcmp(prefix, "slr") == 0 || strcmp(prefix, "sle") == 0;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
|
||||
|
|
|
|||
586
tui/src/sleep_diary.c
Normal file
586
tui/src/sleep_diary.c
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -544,6 +544,8 @@ 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) ||
|
||||
|
|
@ -568,6 +570,8 @@ 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) ||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
#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"
|
||||
|
|
@ -115,6 +116,14 @@ 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;
|
||||
|
|
@ -1398,6 +1407,70 @@ 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;
|
||||
|
|
|
|||
488
tui/src/web_sleep.c
Normal file
488
tui/src/web_sleep.c
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -11,125 +11,69 @@
|
|||
#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');
|
||||
|
|
@ -137,19 +81,15 @@ 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;
|
||||
|
|
@ -158,10 +98,7 @@ 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));
|
||||
|
|
@ -175,12 +112,7 @@ 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;
|
||||
|
|
@ -191,50 +123,20 @@ 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.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.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);
|
||||
|
|
@ -247,67 +149,39 @@ 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);
|
||||
|
|
@ -316,22 +190,15 @@ 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);
|
||||
|
|
@ -340,8 +207,7 @@ 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"};
|
||||
|
|
@ -350,50 +216,53 @@ 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);
|
||||
|
|
@ -406,8 +275,7 @@ 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},
|
||||
|
|
|
|||
|
|
@ -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 == 28);
|
||||
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
trainlog_database_close(database);
|
||||
database = NULL;
|
||||
|
||||
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == 28);
|
||||
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
CHECK(capture_projection(raw,
|
||||
BUSINESS_PROJECTIONS,
|
||||
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
|
||||
|
|
|
|||
|
|
@ -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 == 28);
|
||||
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
trainlog_database_close(production);
|
||||
production = NULL;
|
||||
|
||||
|
|
|
|||
73
tui/tests/test_schema_v29_migration.c
Normal file
73
tui/tests/test_schema_v29_migration.c
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -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 v28: SQLite rc=",
|
||||
strlen("migrate database to schema v28: SQLite rc=")) == 0);
|
||||
"migrate database to schema v29: SQLite rc=",
|
||||
strlen("migrate database to schema v29: 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=29;", NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", 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 29 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL);
|
||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
|||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(database == NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v28") != NULL);
|
||||
CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL);
|
||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
|
|
|
|||
125
tui/tests/test_sleep_diary.c
Normal file
125
tui/tests/test_sleep_diary.c
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -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") == 28);
|
||||
CHECK(scalar(database, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||
CHECK(scalar(database,
|
||||
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
|
||||
"name IN('generation_id','acknowledged_at')") == 2);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
period: 'Période', periods: { '7d': '7 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' },
|
||||
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' },
|
||||
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',
|
||||
period: 'Period', periods: { '7d': '7 days', '30d': '30 days', '90d': '90 days', all: 'All' },
|
||||
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' },
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
export type AnalysisPeriod = '7d' | '30d' | '90d' | 'all'
|
||||
export type AnalysisPeriod = '7d' | '14d' | '21d' | '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', '30d', '90d', 'all']
|
||||
export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '14d', '21d', '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',
|
||||
|
|
|
|||
69
web/src/api/sleepDiary.ts
Normal file
69
web/src/api/sleepDiary.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
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(),
|
||||
})
|
||||
33
web/src/report/sleepDiaryPdf.test.ts
Normal file
33
web/src/report/sleepDiaryPdf.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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')
|
||||
}
|
||||
})
|
||||
})
|
||||
58
web/src/report/sleepDiaryPdf.ts
Normal file
58
web/src/report/sleepDiaryPdf.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -12,8 +12,9 @@ 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";
|
||||
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements" | "sleep";
|
||||
type ExerciseMetric =
|
||||
"duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
|
||||
interface ChartPoint {
|
||||
|
|
@ -33,6 +34,7 @@ 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;
|
||||
|
|
@ -605,7 +607,9 @@ export function AnalysisPage() {
|
|||
? t.exercises
|
||||
: id === "body-zones"
|
||||
? t.distribution
|
||||
: t.measurements}
|
||||
: id === "measurements"
|
||||
? t.measurements
|
||||
: t.sleep}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -870,6 +874,9 @@ export function AnalysisPage() {
|
|||
)}
|
||||
</article>
|
||||
)}
|
||||
{section === "sleep" && (
|
||||
<SleepDiaryWorkspace period={period} language={language} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
82
web/src/routes/SleepDiaryWorkspace.tsx
Normal file
82
web/src/routes/SleepDiaryWorkspace.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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>
|
||||
}
|
||||
|
|
@ -600,6 +600,32 @@ 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue