Compare commits

...

22 commits

Author SHA1 Message Date
fy59
292bcc8246 docs: record final sleep PDF rollout source 2026-10-07 10:20:42 +02:00
fy59
e454c86354 fix(report): keep sport times chronological within each day 2026-10-07 10:20:12 +02:00
fy59
bf5a06973d docs: record served sleep PDF polish evidence 2026-10-07 10:18:38 +02:00
fy59
3dcb7f6c97 fix(web): polish sleep PDF order and publication labels 2026-10-07 10:17:31 +02:00
fy59
a5175fc0ef docs: record restored v0.1.7 web rollout 2026-10-07 09:55:37 +02:00
fy59
700dfc0d5e fix(web): restore sleep and body parity with program calendar 2026-10-07 09:54:33 +02:00
fy59
1c50d483f2 docs: distinguish paired Bluetooth from legacy USB Drive path 2026-10-02 08:46:58 +02:00
fy59
5f16a3e0ff docs: reconcile current architecture workflows and stabilization status 2026-10-02 08:45:26 +02:00
fy59
f4ddcfdb05 fix(web): unify sleep display with weekly page sizes 2026-10-02 07:44:42 +02:00
fy59
f9bb96ae08 fix(web): paginate sleep agenda newest first with page size selection 2026-10-02 07:12:02 +02:00
fy59
8995843e46 fix(analysis): honor current program dates and ceded slots 2026-10-01 23:03:55 +02:00
fy59
e64b9ddcd3 docs: record body figure web rollout 2026-10-01 22:27:29 +02:00
fy59
c2935b81ee fix(web): align body figures with android silhouettes 2026-10-01 22:10:30 +02:00
fy59
469a256715 docs: record served Sleep PDF layout rollout 2026-10-01 21:48:25 +02:00
fy59
3758875dbb fix(web): paginate readable sleep PDF treatments and remarks 2026-10-01 21:42:00 +02:00
fy59
e7439c5177 docs: record verified private Sleep sport rollout 2026-10-01 21:17:28 +02:00
fy59
e2c07f1e80 feat(web): overlay completed sport on Sleep agenda and PDF 2026-10-01 21:06:58 +02:00
fy59
3a4ec34eb9 fix(sync): bound slow MTP rollout and retained generations 2026-10-01 08:20:38 +02:00
fy59
df41300353 fix(program): preserve V1 digest during Android V2 upgrade 2026-10-01 07:53:07 +02:00
fy59
c4df2c3b3f docs(package): require Web assets for rollout 2026-10-01 07:30:37 +02:00
fy59
7c27e7f8ec fix(package): require embedded Web and record Program V2 candidate 2026-10-01 07:15:10 +02:00
fy59
6c0e7af2f0 fix(program): add durable ordered reschedule candidate 2026-10-01 07:00:14 +02:00
113 changed files with 4887 additions and 615 deletions

View file

@ -8,12 +8,13 @@ Trainlog consists of:
- a Unix/Linux C17 terminal TUI for durable history, correction, analysis,
visualization, and manual synchronization, using Notcurses as its completed
active rendering/input backend;
- a loopback-only local Web adapter whose Dashboard is implemented on the
0.1.2 development branch; its Analyse, Programmes, Sessions, and Exercises
routes remain placeholders pending separate contracts;
- a loopback-only local Web adapter with implemented Dashboard, Analyse,
Programmes, Sessions, and Exercises routes on the 0.1.7 development line;
- a small user-session PC agent, `trainlog-syncd`, for Android-triggered
synchronization;
- versioned JSON synchronization artifacts exchanged over direct MTP.
- versioned JSON synchronization artifacts exchanged through configured
Bluetooth Classic RFCOMM, direct MTP recovery, or a separate private Drive
workflow.
The desktop SQLite database is the canonical long-term history.
@ -170,7 +171,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name
heuristics.
Android local SQLite schema v24 retains the exactly-one durable active-session
Android local SQLite schema v34 retains the exactly-one durable active-session
draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit
@ -184,7 +185,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v26 and Android migrations reach v24 without changing
desktop migrations reach v37 and Android migrations reach v34 without changing
that identity contract. The active
completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -241,14 +242,16 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version;
```
The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
The current desktop schema is v37. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
logical Program deletion. Later additive migrations through v37 add Sleep,
heart-rate, Cardio and Program planning state. None changes
`TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and
regression coverage.

View file

@ -2,6 +2,88 @@
## Unreleased — 0.1.7
- Present Sleep PDF nights and completed-sport days newest first while
preserving chronological timed content within each night and sport day.
Add a restrained
printable header, period and generation date, and pale row separation.
Remove unhelpful validation labels from agenda rows and PDF warnings; the
editor now names the exact-revision synchronization publication action and
retains its meaningful state. Sleep facts, schema, sport rules, Android and
synchronization behavior are unchanged. The private daily Web launcher serves
package `e454c86`; Firefox and a downloaded five-page report verified its
newest-first order and factual medication and sport content.
- Recovered the Android-matched Web body figures and factual completed-sport
Sleep agenda/PDF overlay after the Program calendar package was built from
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development
branch now includes the later Sleep PDF, paging and Program analysis fixes.
Kept the compact month calendar, individual drag and drop and accessible
date action, green fixed completed cards, same-day sessions, and revisioned
restoration of legacy ceded optional sessions. No Android source, database
format, or synchronization protocol changed in this correction. The private
daily Web launcher now serves package `700dfc0`; Firefox and a downloaded
PDF verified its factual session overlay.
- Reconciled the canonical documentation against the `f4ddcfd` development
lineage: current source schemas v37/v34, implemented Web routes and Program
rescheduling, observed package provenance, Sleep row paging, capability
selected exchange, Web packaging requirements and validation limits. The
[documentation review](docs/reviews/documentation_deep_review_v1.md) records
the evidence matrix. This entry changes no runtime behavior or format.
- Show the Web Sleep agenda newest first with one 7, 14, 21, 28, or All
recorded-night selector (7 by default). Sleep reads its full available history
without the Analysis period control; other Analysis sections retain their
period choice. The browser-local 5/10/15 preference migrates to 7/14/21,
and a capped full-history read fails explicitly. Page changes leave the
selected night, editor, statistics, and inclusive PDF range untouched.
Removed only the redundant textual sport list below the Web agenda; factual
blue overlays, the sport legend, and the complete PDF sport appendix remain.
- Corrected the Dashboard/Analysis active-Program next-session projection to
select title, identity, and current date from one pending Program session.
Recorded rescheduling dates replace imported dates, ceded slots are excluded,
and an overdue uncompleted session remains eligible. The read-only projection
does not change Program history, preparation priority, or synchronization.
- Aligned the Web front/back BODY ZONES figures with Android's normalized
Compose Canvas silhouettes. The Web now uses Android's head, neck and 11
region paths per face, while retaining the existing Catppuccin intensity
classes, 30-day session counts, legend, labels and keyboard focus behavior.
No Android, domain, database or synchronization behavior changed.
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
separate medication intakes, accents, doses, units, and quantities visible.
Row heights and page count now follow content; exceptional entries continue
on clearly dated pages. The former truncated observations duplicate is gone.
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
Sleep, sport, database, and synchronization data are unchanged.
The private PC/Web rollout from `3758875` was verified with a four-page
browser-downloaded PDF; synchronization services retained their Program paths.
- Added a read-only completed-session overlay to the Web Sleep agenda and its
local vector PDF preview/export. Factual session start and end times share
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a
printable paginated listing expose recorded names and times. Missing ends
remain point markers. The report flags sport without a recorded Sleep row,
and export waits for the complete paginated history read. Sleep entries,
session history, database schema, Android, and synchronization contracts
remain unchanged.
The private PC/Web rollout from `e2c07f1` was verified against its served
agenda and an actual downloaded PDF; the phone installation was untouched.
The isolated `trainlog-program-recalage-v1` candidate adds an explicit
preview-and-confirm cascade for unfinished Program sessions. It retains source
and actual dates, stores absolute current dates and ceded-slot state, and
selects a separate Programs V2 Android projection by capability. The source
candidate remains isolated from the stable checkout while its private desktop
and compatible Android build are installed for the coordinated rollout.
Rollout packaging rejects a native build without the embedded Web frontend.
The private rollout preserves parsed V1 Program number types during Android V2
upgrade and raises the bounded local MTP operation budget to 90 seconds and
retained outgoing generation capacity to 16 after interrupted exchanges.
Development opened after the stable v0.1.6 release. This line is restricted to
observed bug fixes, regression fixes, directly related small UI corrections,
documentation corrections, robustness, and associated tests. It does not admit

View file

@ -145,13 +145,20 @@ cd ..
meson setup build-web -Dweb=enabled
meson compile -C build-web
meson test -C build-web --print-errorlogs
./build-web/tui/trainlog --web
```
The Web application listens on `127.0.0.1:8080` by default. Use
`--port <port>` for an explicit alternative. For an existing build directory,
use `meson configure build -Dweb=enabled` instead of running `meson setup`
again.
again. The separate `npm test` run checks Web source; the enabled Meson build
registers embedded frontend tests and places its assets in the executable.
The default `web=auto` configuration does not by itself prove embedded assets.
For a compatible service bundle, use the versioned candidate packaging
procedure in [rollout readiness](docs/sync_rollout_readiness.md); copying only
`trainlog` omits its matching synchronization helpers. Restarting a service
does not change the package selected by its launcher.
### Android

View file

@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
LOCAL_PERSISTENCE=IMPLEMENTED
DIRECT_STORAGE=IMPLEMENTED
MTP_SYNC=IMPLEMENTED
ANDROID_SCHEMA=33
ANDROID_SCHEMA=34
```
## Visual contract

View file

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

View file

@ -370,6 +370,7 @@ internal class SyncGenerationCoordinator(
ACK_RECOVERY_CAPABILITY,
"session-preparations-v2",
"programs-v1",
"programs-v2",
)
),
)

View file

@ -86,7 +86,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
(ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
Kind("trainlog-programs", 1, "programs-v1.json", false))
Kind("trainlog-programs", 1, "programs-v1.json", false) +
Kind("trainlog-programs", 2, "programs-v2.json", false))
.map { it.format to it.version }
.toSet()
@ -773,6 +774,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
throw SyncGenerationException("artifact digest mismatch")
artifacts[item.getString("logical_name")] = bytes.toString(StandardCharsets.UTF_8)
}
if ("programs-v1" in artifacts && "programs-v2" in artifacts)
throw SyncGenerationException("multiple Program revisions in one generation")
return ValidatedGeneration(manifest, sha256(raw), artifacts)
}
@ -947,6 +950,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("programs-v1") {
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
}
if ("programs-v2" in a)
apply("programs-v2") {
repository.applyProgramsV2Json(checkNotNull(a["programs-v2"]))
}
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
apply("exercise-profile-state") {
repository.applyExerciseProfileStateJson(

View file

@ -11825,7 +11825,39 @@ class TrainlogRepository(
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
*/
fun applyProgramsV1Json(json: String): ProgramsImportResult {
fun applyProgramsV1Json(json: String): ProgramsImportResult = applyProgramsJson(json, 1)
fun applyProgramsV2Json(json: String): ProgramsImportResult = applyProgramsJson(json, 2)
private fun legacyProgramDigest(program: JSONObject): String {
// WHY: serializing and reparsing JSONObject changes integral doubles (for
// example 52.0 to 52), which changes a V1 revision's causal digest.
// CONTRACT: discard only V2 fields while retaining the parsed V1 values.
val legacy = JSONObject()
program.keys().forEach { key ->
when (key) {
"revision_sequence" -> Unit
"sessions" -> {
val rows = JSONArray()
val sessions = program.getJSONArray("sessions")
for (index in 0 until sessions.length()) {
val source = sessions.getJSONObject(index)
val row = JSONObject()
source.keys().forEach { sessionKey ->
if (sessionKey !in setOf("position", "current_for", "planning_state"))
row.put(sessionKey, source.get(sessionKey))
}
rows.put(row)
}
legacy.put(key, rows)
}
else -> legacy.put(key, program.get(key))
}
}
return causalDigest(legacy)
}
private fun applyProgramsJson(json: String, documentVersion: Int): ProgramsImportResult {
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
val root = try { JSONObject(json) } catch (_: Exception) {
@ -11853,7 +11885,7 @@ class TrainlogRepository(
try {
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
root.getInt("version") != documentVersion || !timestamp(root.opt("generated_at")) ||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
throw ProgramsImportInvalid("Unsupported Programs document.")
val programs = root.getJSONArray("programs")
@ -11880,11 +11912,16 @@ class TrainlogRepository(
for (programIndex in 0 until programs.length()) {
val program = programs.optJSONObject(programIndex)
?: throw ProgramsImportInvalid("Invalid program.")
if (!program.exact(
val programKeysValid = if (documentVersion == 2) program.exact(
"program_id", "revision_id", "revision_sequence", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) else program.exact(
"program_id", "revision_id", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) ||
)
if (!programKeysValid ||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
@ -11894,17 +11931,33 @@ class TrainlogRepository(
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
program.opt("sessions") !is JSONArray)
throw ProgramsImportInvalid("Invalid program metadata.")
if (documentVersion == 2 && !integer(program.opt("revision_sequence"), 0, Int.MAX_VALUE))
throw ProgramsImportInvalid("Invalid Program revision sequence.")
val sessions = program.getJSONArray("sessions")
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.optJSONObject(sessionIndex)
?: throw ProgramsImportInvalid("Invalid program session.")
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
val validKeys = if (documentVersion == 2) session.exact(
"program_session_id", "position", "title", "session_type", "planned_for",
"current_for", "planning_state", "note", "occurrences",
) else session.exact(
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences",
)
if (!validKeys ||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
session.opt("occurrences") !is JSONArray)
throw ProgramsImportInvalid("Invalid program session metadata.")
if (documentVersion == 2 &&
(!integer(session.opt("position"), 0, 255) || session.getInt("position") != sessionIndex ||
!date(session.opt("current_for")) ||
session.optString("planning_state") !in setOf("active", "ceded") ||
(session.optString("planning_state") == "ceded" && !session.isNull("current_for")) ||
(session.optString("planning_state") == "active" &&
session.isNull("current_for") && !session.isNull("planned_for"))))
throw ProgramsImportInvalid("Invalid Program planning state.")
val occurrences = session.getJSONArray("occurrences")
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
val entryIds = mutableSetOf<String>()
@ -11930,6 +11983,12 @@ class TrainlogRepository(
}
}
data class ExistingProjection(
val revisionId: String,
val digest: String,
val version: Int,
val sequence: Long,
)
val db = database.writableDatabase
db.beginTransaction()
try {
@ -11962,28 +12021,76 @@ class TrainlogRepository(
.use { it.moveToFirst() }
if (tombstoned) { skipped++; continue }
val digest = causalDigest(program)
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
if (existing?.first == program.getString("revision_id")) {
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
skipped++
continue
val existing = db.rawQuery(
"SELECT revision_id,snapshot_sha256,projection_version,revision_sequence " +
"FROM synced_programs WHERE program_id=?",
arrayOf(id),
).use { cursor ->
if (!cursor.moveToFirst()) null else ExistingProjection(
cursor.getString(0), cursor.getString(1), cursor.getInt(2), cursor.getLong(3),
)
}
val sequence = if (documentVersion == 2) program.getLong("revision_sequence") else 0L
// WHY: a later V1 artifact cannot erase current dates or a
// ceded slot after this phone has accepted Programs V2.
if (existing != null && documentVersion < existing.version)
throw ProgramsImportInvalid("Programs V2 capability required.")
if (existing != null && documentVersion == 2 &&
(sequence < existing.sequence ||
(sequence == existing.sequence && existing.version == 2 &&
existing.revisionId != program.getString("revision_id"))))
throw ProgramsImportInvalid("Stale Program revision sequence.")
if (existing?.revisionId == program.getString("revision_id")) {
if (existing.digest == digest) { skipped++; continue }
if (documentVersion != 2 || existing.version != 1 ||
existing.digest != legacyProgramDigest(program))
throw ProgramsImportInvalid("Program revision identity conflict.")
}
if (documentVersion == 2) {
val activeProgramSession = db.rawQuery(
"SELECT source_program_session_id FROM active_session_draft " +
"WHERE id=1 AND source_program_id=?",
arrayOf(id),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
if (activeProgramSession != null) {
val newSessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until newSessions.length()) {
val candidate = newSessions.getJSONObject(sessionIndex)
if (candidate.getString("program_session_id") == activeProgramSession) {
val existingPlan = db.rawQuery(
"SELECT current_for,planning_state FROM synced_program_sessions " +
"WHERE program_session_id=?",
arrayOf(activeProgramSession),
).use { cursor ->
if (!cursor.moveToFirst()) null else
(if (cursor.isNull(0)) null else cursor.getString(0)) to cursor.getString(1)
}
if (existingPlan != null &&
(existingPlan.first != candidate.optStringOrNull("current_for") ||
existingPlan.second != candidate.getString("planning_state")))
throw ProgramsImportInvalid("Active Program draft blocks reschedule.")
}
}
}
}
db.delete("synced_programs", "program_id=?", arrayOf(id))
db.execSQL(
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest,
documentVersion, sequence),
)
val sessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex)
val sessionId = session.getString("program_session_id")
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
session.optStringOrNull("planned_for"), session.optStringOrNull("note"),
if (documentVersion == 2) session.optStringOrNull("current_for") else session.optStringOrNull("planned_for"),
if (documentVersion == 2) session.getString("planning_state") else "active"))
val entries = session.getJSONArray("occurrences")
for (entryIndex in 0 until entries.length()) {
val entry = entries.getJSONObject(entryIndex)
@ -12052,7 +12159,7 @@ class TrainlogRepository(
} ?: return null
val sessions = mutableListOf<SyncedProgramSession>()
db.rawQuery(
"SELECT program_session_id,title,session_type,planned_for,note " +
"SELECT program_session_id,title,session_type,planned_for,note,current_for,planning_state " +
"FROM synced_program_sessions WHERE program_id=? " +
"ORDER BY position,program_session_id",
arrayOf(programId),
@ -12095,6 +12202,8 @@ class TrainlogRepository(
sessionCursor.getString(1),
sessionCursor.getString(2),
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
if (sessionCursor.isNull(5)) null else sessionCursor.getString(5),
sessionCursor.getString(6),
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
execution.first,
execution.second,
@ -12160,7 +12269,8 @@ class TrainlogRepository(
val available = db.rawQuery(
"SELECT s.session_type FROM synced_program_sessions s " +
"JOIN synced_programs p ON p.program_id=s.program_id " +
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active'",
"WHERE p.program_id=? AND s.program_session_id=? AND p.state='active' " +
"AND s.planning_state='active'",
arrayOf(programId, programSessionId),
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
?: return StartProgramSessionResult.NotAvailable
@ -12454,7 +12564,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
33,
34,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -12808,6 +12918,34 @@ private class TrainlogDatabaseHelper(
createSleepImmutabilityTriggers(db)
version = 33
}
if (version < 34 && newVersion >= 34) {
/* WHY: V1 retains the source date; V2 needs an independent current
* target and a durable ceded-slot state. INVARIANT: no local draft
* or completed workout is rewritten by the projection migration. */
if (!tableHasColumn(db, "synced_programs", "projection_version")) {
db.execSQL(
"ALTER TABLE synced_programs ADD COLUMN projection_version INTEGER NOT NULL " +
"DEFAULT 1 CHECK(projection_version IN(1,2))",
)
}
if (!tableHasColumn(db, "synced_programs", "revision_sequence")) {
db.execSQL(
"ALTER TABLE synced_programs ADD COLUMN revision_sequence INTEGER NOT NULL " +
"DEFAULT 0 CHECK(revision_sequence>=0)",
)
}
if (!tableHasColumn(db, "synced_program_sessions", "current_for")) {
db.execSQL("ALTER TABLE synced_program_sessions ADD COLUMN current_for TEXT")
db.execSQL("UPDATE synced_program_sessions SET current_for=planned_for")
}
if (!tableHasColumn(db, "synced_program_sessions", "planning_state")) {
db.execSQL(
"ALTER TABLE synced_program_sessions ADD COLUMN planning_state TEXT NOT NULL " +
"DEFAULT 'active' CHECK(planning_state IN('active','ceded'))",
)
}
version = 34
}
if (version != newVersion) {
error(
@ -12824,7 +12962,9 @@ private class TrainlogDatabaseHelper(
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL,
projection_version INTEGER NOT NULL DEFAULT 1 CHECK(projection_version IN(1,2)),
revision_sequence INTEGER NOT NULL DEFAULT 0 CHECK(revision_sequence>=0)
)""".trimIndent(),
)
db.execSQL(
@ -12833,7 +12973,9 @@ private class TrainlogDatabaseHelper(
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
planned_for TEXT, note TEXT, current_for TEXT,
planning_state TEXT NOT NULL DEFAULT 'active' CHECK(planning_state IN('active','ceded')),
UNIQUE(program_id,position)
)""".trimIndent(),
)
db.execSQL(

View file

@ -25,6 +25,8 @@ data class SyncedProgramSession(
val title: String,
val sessionType: String,
val plannedFor: String?,
val currentFor: String?,
val planningState: String,
val note: String?,
val executionState: ProgramSessionExecutionState,
val executionSessionId: String?,

View file

@ -118,7 +118,12 @@ fun ProgramDetailScreen(
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
val ceded = program.sessions.count { it.planningState == "ceded" }
TrainlogInfo(
"$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}" +
if (ceded > 0) " · $ceded créneau(x) cédé(s)" else "",
if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted,
)
}
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session ->
@ -145,12 +150,16 @@ fun ProgramDetailScreen(
color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(strings.getString(stateLabel), completed)
ProgramStateBadge(
if (session.planningState == "ceded" && !completed) "Créneau cédé"
else strings.getString(stateLabel),
completed,
)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
session.currentFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "),
)
@ -164,7 +173,7 @@ fun ProgramDetailScreen(
Modifier.testTag("program-session-toggle-${session.programSessionId}"),
)
when (session.executionState) {
ProgramSessionExecutionState.TODO -> TrainlogButton(
ProgramSessionExecutionState.TODO -> if (session.planningState == "active") TrainlogButton(
strings.getString(R.string.program_session_start),
{ start(session.programSessionId) },
Modifier.testTag("program-session-start-${session.programSessionId}"),

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
).use { assertEquals(34, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
).use { assertEquals(34, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null,

View file

@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,

View file

@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +

View file

@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
assertEquals(34, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," +

View file

@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
migrated.listSleepDiary()
migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(34, scalar(db, "PRAGMA user_version"))
assertEquals(
1,
scalar(

View file

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

View file

@ -70,6 +70,91 @@ class ProgramsProjectionRepositoryTest {
.put("deletions", deletions)
.toString()
private fun artifactV2(
program: JSONObject,
sequence: Int = 0,
currentFor: String = "2026-10-01",
): String {
program.put("revision_sequence", sequence)
val session = program.getJSONArray("sessions").getJSONObject(0)
session.put("position", 0)
session.put("current_for", currentFor)
session.put("planning_state", "active")
return JSONObject()
.put("format", "trainlog-programs")
.put("version", 2)
.put("generated_at", "2026-09-18T12:00:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
}
@Test
fun v2UpgradePreservesCurrentDateAndRejectsLegacyDowngrade() {
val name = "program-v2-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
val upgraded = program()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(upgraded)),
)
assertEquals("2026-10-01", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
val stale = artifactV2(program())
val newer = program().put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(newer, 1, "2026-10-02")),
)
assertTrue(repository.applyProgramsV2Json(stale) is ProgramsImportResult.Invalid)
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
assertTrue(repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
is ProgramsImportResult.Invalid)
assertEquals("2026-10-02", repository.getSyncedProgram(upgraded.getString("program_id"))!!
.sessions.single().currentFor)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
assertEquals("2026-10-02", repository.getSyncedProgram(program().getString("program_id"))!!
.sessions.single().currentFor)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun v2UpgradeKeepsV1DigestForIntegralDecimalWeights() {
val name = "program-decimal-upgrade-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
// The desktop JSON exporter writes SQLite REAL values as 40.0.
// JSONObject's own serialization writes the same value as 40.
val v1 = artifact(JSONArray().put(program()))
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
val v2 = artifactV2(program())
.replace("\"target_weight_kg\":40", "\"target_weight_kg\":40.0")
assertTrue(v1.contains("\"target_weight_kg\":40.0"))
assertTrue(v2.contains("\"target_weight_kg\":40.0"))
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(v1))
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV2Json(v2))
assertEquals("2026-10-01", repository.getSyncedProgram(program().getString("program_id"))!!
.sessions.single().currentFor)
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV2Json(v2))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun preparationArtifact(
includeProgramProvenance: Boolean = true,
deliveryState: String = "pending",
@ -211,6 +296,12 @@ class ProgramsProjectionRepositoryTest {
val active = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(programId, active.sourceProgramId)
assertEquals(programSessionId, active.sourceProgramSessionId)
val changedPlan = program().apply {
put("revision_id", "pgr_99999999-9999-4999-8999-999999999999")
}
assertTrue(repository.applyProgramsV2Json(artifactV2(changedPlan))
is ProgramsImportResult.Invalid)
assertEquals(null, repository.getSyncedProgram(programId)!!.sessions.single().currentFor)
val performed = active.copy(
exercises = active.exercises.map { occurrence ->
occurrence.copy(sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)))
@ -218,6 +309,12 @@ class ProgramsProjectionRepositoryTest {
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV2Json(artifactV2(changedPlan)),
)
assertEquals("2026-10-01", repository.getSyncedProgram(programId)!!
.sessions.single().currentFor)
assertEquals(
ProgramSessionExecutionState.COMPLETED,
repository.getSyncedProgram(programId)!!.sessions.single().executionState,
@ -238,6 +335,8 @@ class ProgramsProjectionRepositoryTest {
try {
val detail = repository.listSyncedPrograms().single()
assertEquals(1, detail.sessionCount)
assertEquals("2026-10-01", repository.getSyncedProgram(detail.programId)!!
.sessions.single().currentFor)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertEquals(1, repository.listSessions().size)
} finally {

View file

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

View file

@ -245,7 +245,7 @@ class SleepDiaryRepositoryTest {
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertEquals(34, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",

View file

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

View file

@ -2382,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(33, cursor.getInt(0))
assertEquals(34, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2509,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(33, cursor.getInt(0))
assertEquals(34, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -19,6 +19,8 @@ evidence and chronology.
existing-artifact synchronization boundary.
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow.
- [Sleep Diary guide](sleep_diary.md) explains capture, agenda row paging,
factual sport and heart-rate displays, and independent PDF range selection.
- [Program format V1](program_format_v1.md) owns the strict desktop planning
import contract and program lifecycle used by the Sessions Programs subtab.
@ -80,6 +82,9 @@ evidence and chronology.
- [Review records](reviews/) retain historical audits, incidents, checkpoints,
and validation evidence. Their status and paths are historical.
- [Documentation deep review V1](reviews/documentation_deep_review_v1.md)
records the audited 0.1.7 source baseline, corrected claims, evidence limits,
and coverage matrix.
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
records the implemented, not-deployed validation checkpoint and real-browser
screenshot evidence.

View file

@ -1,5 +1,21 @@
# Android application
## Schema v34: read-only Programs V2 projection
The audited Program rescheduling development line additively stores a projection
version and revision sequence on synchronized Programs, plus `current_for` and
`planning_state` on synchronized Program sessions. The original `planned_for`
and all local completed sessions and drafts remain separate. Existing V1 rows
hydrate as active with their original date. Android still has no Program editor.
Programs V2 replaces one Program snapshot transactionally. A lower revision
sequence or a later Programs V1 snapshot cannot overwrite accepted V2 planning.
If the singleton active draft refers to a session whose current date or ceded
state changes, V2 import rejects the generation; it does not alter the draft.
The Program screen uses current dates for unfinished sessions and does not offer
Start on a ceded slot. The private Program rollout records a compatible v34
Android installation; this source snapshot alone does not prove another device.
## Sleep Diary V1 and active V2 companion
Android schema v26 additively mirrors the Sleep Diary revision/event model for

View file

@ -3,12 +3,13 @@
## Synchronization transport boundary
Generation capture, validation, causal import, publication and ACK acceptance
are transport-neutral. `sync_peer_worker.py` owns that conversation; direct MTP
and `sync_drive_transport.py` only move its bounded bytes. Automatic selection
probes the expected MTP peer before preferring USB. A successful USB run is
committed independently, then mirrored to Drive best-effort; Drive failure can
never reopen its SQLite transactions. If MTP is unavailable, the same worker
runs over the Drive byte adapter.
are transport-neutral. `sync_peer_worker.py` owns that conversation. The paired
private automatic path selects authenticated Bluetooth first and direct MTP as
wired recovery. The separately configured legacy USB/Drive workflow probes the
expected MTP peer before selecting USB. A successful USB run is committed
independently, then mirrored to Drive best-effort; Drive failure cannot reopen
its SQLite transactions. If MTP is unavailable in that workflow, the same
worker runs over the Drive byte adapter. No transport carries a SQLite file.
Android mirrors that ownership: one `SyncGenerationCoordinator` is called by
SyncScreen, the visible USB foreground service, and the private-folder Drive
@ -42,7 +43,7 @@ Android field client
Android shared storage
Documents/Trainlog
|
| direct MTP
| Bluetooth RFCOMM primary; direct MTP recovery
v
shared desktop sync engine
/ \
@ -358,11 +359,16 @@ SVG was selected over Canvas because the bounded series has very few elements
and vector sharpness/responsive scaling matter more than high-volume drawing.
All colours are read from the live Catppuccin CSS variables.
The muscle figure is an original code-native SVG. Canonical localized mappings
are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
`thighs` and `calves` to bilateral front/rear legs. Aggregate zones
The muscle figure is a code-native SVG whose geometry is ported from the
normalized Compose Canvas in Android `HomeScreen.kt`: `drawOval()` for the
head, `neckPath()` for the neck, and `bodyRegions()` for 11 paths on each
face. The SVG viewBox uses Android fractions multiplied by 100 and scales to
a portrait ratio based on Android's 236dp tall two-panel Home layout.
Canonical localized mappings are: `chest` to
bilateral front chest; `back` to the contiguous rear trunk; `shoulders` to
bilateral front/rear shoulders; `arms` to bilateral front/rear arms; `core` to
the front trunk; `glutes` to bilateral rear glutes; `thighs` and `calves` to
bilateral front/rear legs. Aggregate zones
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
remain in the complete text list. Visual levels are a deterministic ratio to
the maximum `session_count` in the current snapshot: zero, up to one third, up
@ -519,12 +525,14 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
v26 migration adds terminal Program logical deletion and its durable replay,
generation, and acknowledgement ledger without deleting imported definitions,
derived preparations, or workout facts. Android schema v24 maintains the
separate read-only synchronized Programs projection; the optional staged
`trainlog-programs` V1 companion does not replace mobile V3 or change
Desktop SQLite schema v37 is canonical long-term history in the audited
development source. The additive v37 migration stores absolute Program current
dates, ceded slots and revision ordering. The earlier v25 -> v26 migration
added terminal Program logical deletion and its durable replay, generation and
acknowledgement ledger without deleting imported definitions, derived
preparations or workout facts. Android schema v34 extends the read-only
Programs projection with capability-selected V2 planning state; v24 introduced
its original V1 projection. Neither Programs companion replaces mobile V3 or changes
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
manual-preparation withdrawal identity without deleting revisions, deliveries
or workout facts. Its additive v18 ->
@ -584,7 +592,7 @@ exercise_body_zone_sync
### Android
Android has an independent local SQLite schema, currently v14. Completed and
Android has an independent local SQLite schema, v34 in the audited source. Completed and
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
resuming a completed Test max records its stable source session in the one
durable draft.

View file

@ -1,5 +1,47 @@
# Current implementation state
## Audited development line and private installation
This 0.1.7 correction joins the audited `f4ddcfd` development lineage,
including the Program projection fix `8995843`, Android-matched Web body
figures `c2935b8`, and the Sleep PDF, sport and paging changes, to the compact
Program month calendar. The uncommitted Dashboard layout work in the separate
`trainlog-dashboard-polish-v1` worktree remains outside this snapshot.
The private daily Program rollout uses desktop schema v37, Android schema v34,
and the capability-selected `trainlog-programs` V2 projection. The later
`program-calendar-dnd-drop-failure-v1-final` PC/Web package declared source
commit `3a4ec34` and carried the uncommitted calendar UI, but omitted the
subsequent body-figure and Sleep sport commits. The corrected package from
`700dfc0` is installed as the daily Web launcher; the live service executable,
health endpoint and Firefox views were checked on 2026-10-07. The corrected
Web uses Android Compose Canvas head, neck, and 22 body
paths on Dashboard muscle distribution, Analysis overview, and Analysis body
distribution. Firefox captures of all three served views show complete figures
at the intended aspect ratio, with the existing color scale, legend, and focus
interaction. The candidate geometry was compared with reconstructed Android
source paths; no Android runtime screenshot was available because no device or
emulator was connected. No Android, schema, domain, or synchronization change
accompanied this Web rollout.
Firefox downloaded a four-page PDF from the served Web. The earlier PDF layout
validation rendered and inspected every page: its 65 individual medication
lines match its summary, its remarks remain visible, and no extracted word
exceeds the page margins. After the body figure rollout, a fresh PDF export
again contained the treatments and remarks sections; actual sport markers
remained blue in Web, and a recorded start time matched the PDF. The earlier
PDF inspection also confirmed black-outlined blue sport bands. A separate
synthetic set of 7, 14, 21, and 30 nights in French and English, plus long
continuations, was rendered page by page and checked in grayscale. Rollout
packaging requires a Meson `-Dweb=enabled` build with embedded frontend tests.
The corrected build was checked in Firefox first with a snapshot and then
against the live daily Web service without mutating user data. The latest seven
Sleep rows showed six blue sport marks,
including the completed 2026-10-07 session at 06:59–08:02. The downloaded
five-page PDF listed that same session and its 1 h 03 min duration on its
sport appendix. The October Program calendar showed 35 day cells, seven
completed fixed cards and twelve movable cards without a data mutation.
Full-generation synchronization uses one generation/ACK business engine across
versioned transports. On the paired 0.1.4 deployment, automatic local selection
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
@ -46,7 +88,7 @@ callbacks complete before the mutable generation reference is published; the
reference and Android ACK use the same confirmation. Bounded diagnostic logs
contain only run/generation identities, artifact names, phases and results.
The desktop worker performs lightweight local correlation checks continuously
but opens a full MTP pull no more than once every three seconds. A 30-second
but opens a full MTP pull no more than once every three seconds. A 90-second
individual adapter budget is distinct from the overall conversation deadline,
so an absent correlated generation reports a protocol wait timeout while an
actually blocked adapter reports `transport_timeout`.
@ -141,8 +183,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
| Desktop SQLite | source schema v37; v37 adds Program current dates, ceded slots and revision ordering; v36 makes Sleep revision payload append-only; v35 adds intake quantity |
| Android SQLite | source schema v34; v34 adds the read-only Programs V2 projection; v33 makes Sleep revision payload append-only; v32 adds intake quantity and quick publication |
| 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.7` bugfix-only development, synchronized across Android and desktop; latest stable release: `v0.1.6` |
@ -206,13 +248,53 @@ parallel implementation of its rules.
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
The active Sleep V2 view reads the complete available Sleep history independently
of the Analysis period used by other sections. Every returned night remains
available on the shared 18:00-to-18:00 geometry. Sleep has one agenda display
selector: the newest nights first, seven recorded nights per page by default,
with 7, 14, 21, 28, and All choices. These are row counts, not calendar windows.
The browser-local choice survives reloads; old 5/10/15 choices migrate to
7/14/21. At the server's 3660-night read cap, the Web reports that completeness
cannot be established instead of presenting a truncated agenda as All. Paging
changes only the visible rows; PDF selection, statistics, and the current editor
remain based on the complete loaded history. Selecting a row is
presentation-only and atomically drives the detail dates, editor content, and
full-width heart-rate detail; it performs no database mutation. PDF preview
and download instead share an explicit inclusive range over night start dates,
initialized to the loaded agenda bounds and independent of detail selection. Its
rise/fall overlays are descriptive relative measured
initialized to the loaded history bounds and independent of detail selection.
The vector PDF now presents selected nights newest first, while retaining
chronological timed content within each night; its sport appendix shows newest
days first and chronological sessions within each day. A light header, period,
generation date and pale alternating rows
improve print hierarchy in color and grayscale. Agenda rows and PDF omit the
publication/validation labels. The editor still exposes the exact-revision
publication action and state required for synchronization; PDF selection and
local editing do not depend on that state.
The private daily Web launcher now serves the 0.1.7 PDF polish package from
`e454c86`. Read-only Firefox inspection of the served application on
2026-10-07 downloaded a five-page report from existing data: its first night
was 2026-10-06, all 17 nights and 98 medication intakes remained present, and
the completed 2026-10-07 sport session retained its factual times and duration.
Color and grayscale renders remained legible. No personal Sleep data were
edited for this validation.
The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
from desktop history through a read-only, paginated Web endpoint.
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
intersect `[start,end)` and are clipped only for display; missing or invalid
ends are point markers without invented duration. The Web keeps a separate
blue lane with accessible labels and tooltips; the redundant textual Web sport
list is removed. Preview and download wait for the
complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
width-aware wrapping, content-driven row heights, and dated continuation pages
for exceptional notes. Each selected intake and remark is printed once without
a fixed character limit. The shorter 430 pt timeline shares one coordinate
mapping across hour ticks, grid, sleep markers, medication markers, and blue
sport bands. The PDF prints blue
outlined bands and paginated names, factual times, and durations, marking
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
creates no Sleep event and changes no sleep total, session, schema, or sync
contract. The HR detail's rise/fall overlays are descriptive relative measured
variation only (five-minute trailing reference excluding the current 60-second
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
are not sleep events, sleep stages, causes, readiness, or medical inference.
@ -420,7 +502,7 @@ persistence with optimistic conflict and CSRF/Origin protection. It provides no
automatic browser launch. Sessions provides complete paged preparation,
resume and history views, stable details, optimistic manual-preparation writes,
explicit proposal derivation and generation-backed Android delivery. The
top-level `/programmes` route is an operational daily active-Program calendar;
top-level `/programmes` route is a compact monthly active-Program calendar;
Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. `/exercices` is the operational desktop
catalogue administration surface. `/analyse` is implemented with Overview,
@ -449,7 +531,7 @@ catalogue state. Built-in identities cannot be retired, equipment associations
are presented read-only, and profile edits affect future use only: occurrence
snapshots, performed sets, feedback and MAX history are never rewritten. A
successful mutation changes only the canonical desktop database; the existing
USB-priority, Drive mirror/fallback synchronization later carries the existing
configured generation synchronization later carries the existing
catalogue/profile/BODY ZONES/causal artifacts. No Web mutation invokes sync.
The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
@ -457,18 +539,21 @@ The calendar uses the existing typed Core services `fetchAllPrograms(active)`,
or execution state. With no active Program it shows an empty state and
navigation to Sessions administration; with one it selects it automatically;
with several it offers a non-persisted selector. Its continuous Monday–Sunday
weeks derive from Program bounds, imported `planned_for` dates, and the actual
weeks derive from Program bounds, each unfinished session's current date
(`current_for` when present, otherwise imported `planned_for`), and the actual
`sessions.started_at` instant for linked completed executions, presented on the
same browser-local civil day as Web session history. The Program detail Web API
adds the nullable read-only `execution_started_at` projection field; the
Programs synchronization artifact and its strict fields are unchanged. A completed
Programs V2 projection carries the current dates and ceded-slot state to
capable Android peers without changing the strict Programs V1 artifact. A completed
session moved from its imported date shows both dates without changing history
or Program identity. Several cards may share a day; rest days contain no
invented session. A completed execution with no valid history timestamp stays
undated rather than appearing to have occurred on its planned day. Unfinished
sessions still show their imported dates because no durable current-plan or
ceded-optional-slot state exists in the stabilized Programs exchange. It shows
all five Core execution states; only `todo` offers Prepare. Each Prepare result
sessions show their current dates; ceded unfinished slots remain visible but
are excluded from next-session selection and preparation, and are not counted
as performed. It shows all five Core execution states; only eligible `todo`
offers Prepare. Each Prepare result
is followed by an authoritative Core reread and may expose the returned
preparation link. This calendar adds no Android delivery, start, or execution
behavior.
@ -707,9 +792,12 @@ partial, invalid and transport-error states never synthesize values.
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` uses a lazy, modular Apache
ECharts 6.1.0 SVG line chart for medium/large Progression tiles. Every real
point remains visible, `smooth=false`, legacy zeroes are preserved, and only
Core-owned `improved` flags alter markers. An original React SVG supplies
front/back BODY ZONES regions whose discrete colour level depends solely on
`session_count`; the factual text list remains authoritative and accessible.
Core-owned `improved` flags alter markers. A React SVG ports the exact
normalized head, neck, and 11 front/11 back BODY ZONES paths from Android
`HomeScreen.kt` `bodyRegions()` and `neckPath()`. Its discrete Catppuccin colour
level still depends solely on `session_count`; the factual text list remains
authoritative and accessible. The figure geometry changes no zone mapping or
30-day calculation.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus

View file

@ -1,5 +1,21 @@
# Desktop database
## Schema v37: Program planning
Schema v37 is additive to the Program rescheduling development line. It
creates `program_session_planning` for absolute current dates and ceded-slot
state, `program_reschedule_operations` for exact request replay, and
`program_revision_sequences` for monotonically ordered Program snapshots.
A trigger increments the per-Program sequence whenever its revision changes,
including archive, reschedule and logical deletion. No date uniqueness or
historical session mutation is introduced. Missing planning rows mean the
original `planned_for` date and active planning state. The source format and
published Programs V1 artifact remain strict and unchanged.
This schema is present in the audited development commit. Its presence in
source does not establish the schema of another installation; a deployment
requires a compatible package, backup and migration verification.
## Causal heart-rate tail correction (schema v36, no migration)
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
@ -240,8 +256,8 @@ is a later, separately versioned migration.
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=28
DATABASE_SCHEMA_V28=IMPLEMENTED
TRAINLOG_DATABASE_SCHEMA_VERSION=37
DATABASE_SCHEMA_V37=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN
```
@ -260,7 +276,7 @@ PRAGMA user_version;
Current value:
```text
27
37
```
The independent actual-set loads documented in the current desktop, Android
@ -719,7 +735,7 @@ distance km
The Android SQLite database is independent.
Current Android-local version: **17**. The explicit migration chain adds the
Current Android-local version: **34**. The historical migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw

View file

@ -68,3 +68,43 @@ source definition and derived preparations. A deleted program cannot be
resurrected or prepared; an archived program cannot be prepared. The optional
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format.
## Program calendar planning in the audited development line
The `trainlog-program-recalage-v1` line adds a separate planning
state. `planned_for` remains the imported date. `current_for` is an absolute
current target for an active, unfinished session; `ceded` releases one selected
future slot without completing or deleting its session identity. Completed
cards still use the linked session's actual start timestamp. Positions from the
imported Program determine the cascade order; titles never determine priority.
The compact Web month calendar moves one eligible unfinished session to a
chosen civil date by drag and drop or an accessible date action. Other sessions
retain their current dates, and several cards may occupy one day. Completed
cards use factual execution dates and cannot move. FACULTATIVE is descriptive
metadata and never authorizes automatic deletion. The calendar no longer offers
the cascade or ceded-slot selection. Existing ceded rows remain readable and
can be restored to their imported dates with the revisioned, idempotent
planning command. The Programs V2 companion projects the resulting current
dates through normal synchronization.
The retained ordered-rescheduling API selects a first and last unfinished
session, a resumption date, and the exact session identities whose slots are
ceded. Its available days are
the dated original Program slots in that interval on or after resumption.
The preview lists original, prior current and proposed dates. It rejects a
missing slot, a date beyond the Program end, a completed/in-progress/prepared
session, or a stale revision. It does not invent rest-day or post-end slots.
An explicitly ceded session keeps its identity and remains visible as ceded.
Unchanged sessions in the selected range are displayed in the preview but need
no planning row. Other sessions and Programs are not moved. Multiple actual or
independent sessions may still share one civil day.
Confirmation binds the preview fingerprint to the current Program projection
and writes absolute dates, ceded states, the Program revision and a durable
operation response in one transaction. Exact operation replay returns that
response; changed content under the same operation ID conflicts. A failed
write rolls back every row. The development implementation requires a recent acknowledged
Programs V2 generation for every previously contacted Android peer. A newly
started offline Android Program draft remains protected by Android's V2 import
rejection; the sync run then reports a conflict for explicit resolution.

View file

@ -0,0 +1,82 @@
# Documentation deep review V1 — 2026-10-02
## Scope and evidence hierarchy
This review covers the linear development source at
`f4ddcfdb05e9627397f7225c05f40212e9de1ea6`
(`trainlog-sleep-single-selector-weeks-v1`). Its ancestors include `3a4ec34`
(Program rollout), `8995843` (current Program projection), `c2935b8` (Web body
figures), and the Sleep sport/PDF/paging changes. It does not integrate or
describe as delivered the uncommitted Dashboard layout work in the separate
`trainlog-dashboard-polish-v1` worktree. Stable v0.1.6 and the 0.1.7
development line are different publication states.
Normative frozen contracts take priority over source implementation; source and
tests establish capabilities of this commit; dated rollout records establish
only the installation they observed; design/review files retain history. The
observed PC/Web launcher path names the `f4ddcfd` package. That observation
alone is not a byte comparison of a running process, an Android APK inspection,
or proof of every synchronization service's package. The earlier verified
Program rollout records desktop/Android schemas v37/v34. No private health
data, PDF, database or device identifier was read for this review.
## Coverage inventory
The 105 tracked baseline Markdown files include root `README.md`, `AGENTS.md`,
`CHANGELOG.md`, `android/README.md`, `format/README.md`, 17 primary `docs/*.md`
owners, domain and design records, release notes and historical reviews. This
revision adds an indexed Sleep guide and this review. The primary owners were
inspected against code, migration constants, Meson/Gradle settings, tests and
retained rollout records. Designs, releases and reviews were mapped as dated
evidence rather than rewritten into current status. The tracked Markdown
relative-link scan found no missing target before publication. External link
availability was not verified.
| Document/section | Claim | Evidence (commit, path, symbol or test) | Gap | Correction | Status |
| --- | --- | --- | --- | --- | --- |
| `AGENTS.md` scope | Analyse, Programmes, Sessions and Exercises are placeholders | `web/src/routes/`; `docs/web_sessions.md`; `docs/web_exercises.md` at `f4ddcfd` | Obsolete present tense | Names implemented routes | Corrected |
| `AGENTS.md`, database and Android status | Current schemas v26/v24, v28/v17 or v36/v33 | `tui/include/trainlog/database.h` `TRAINLOG_DATABASE_SCHEMA_VERSION=37`; `TrainlogRepository.kt` helper `version=34`; migration tests | Mixed historical and current versions | Current source v37/v34; historical migration steps retained | Corrected |
| `current_state.md`, roadmap | `c2935b8`/`3758875` Web package served | Observed launcher target names `sleep-single-selector-weeks-v1-f4ddcfd`; ancestor graph | Package claim stale; running bytes unverified | Reports launcher observation and separate Program rollout | Corrected with limit |
| Sleep agenda | Old selector or calendar-week interpretation | `SleepDiaryWorkspace.tsx` `initialAgendaPageSize`, `recentEntries`, `visibleEntries`; Sleep component tests | Seven recorded rows can span more than seven dates | Current 7/14/21/28/All row counts, default seven; old values migration only | Corrected |
| Sleep PDF and sport | Page controls could be confused with export range | `SleepDiaryWorkspace.tsx` `pdfStartDate`/`pdfEndDate`; `sleepVisualProjection`; Web PDF tests | Selection and visible page have different scopes | Documents independent inclusive range and factual sport bands | Verified in source |
| Programs | Rescheduling labelled future/candidate | `docs/program_format_v1.md`; `docs/sync_exchange.md`; `tui/tests/test_web_programs.c`; Android v34 projection | Development capability mislabelled future | Describes implemented development contract and partial V1-peer convergence | Corrected |
| Web package | `npm test` could be mistaken for embedded runtime proof | `meson_options.txt` `web`; `tui/meson.build` frontend target/tests; `tools/package_sync_candidate.py` asset gate | Source tests and package differ | README requires `-Dweb=enabled`, Meson tests, compatible bundle | Corrected |
| Synchronization | One ACK might imply universal convergence | `docs/sync_exchange.md` Programs V2 matrix; generation and ACK implementation | V1-only peer omits rescheduled Program while other domains proceed | Added domain/format/selection/ACK matrix | Corrected |
| Transport narrative | Current Web edits and architecture described USB as universal priority | `docs/sync_exchange.md` Bluetooth selector; `tools/sync_peer_worker.py`; private rollout record | Conflated paired Bluetooth path with separate USB/Drive workflow | Separated automatic paired choice from legacy configured workflow | Corrected |
| Rollout readiness | Historical eight-generation limit could look universal/current | `tools/sync_generation_exchange.py` `MAX_RETAINED_PER_PEER=16`; Android `SyncGenerationService.MAX_RETAINED_PER_PEER=8`; `tools/sync_peer_worker.py` MTP timeout | Desktop/Android ceilings differ; timeout is not success proof | Separated limits and historical bridge instructions | Corrected |
| Sleep user path | Capture, agenda and PDF behavior had no short indexed guide | `TrainlogRepository.kt`; `SleepDiaryWorkspace.tsx`; Sleep projection/PDF tests | Readers had to assemble workflow from state and code | Added indexed factual guide with privacy and evidence boundaries | Corrected |
| Backup/rollback | Old executable could be mistaken for schema rollback | `tools/backup_trainlog_sqlite.py` SQLite backup API; `docs/sync_rollout_readiness.md` | Needs paired compatibility and retained backups | Existing procedure retained and linked from README | Verified in documentation |
| Tests | Largest historical totals could look like current validation | `docs/tests.md` dated checkpoints; `tui/meson.build`; Web scripts | Build configuration and skipped tests were implicit | Added evidence-reading rule | Corrected |
| 0.1.8 | Possible training ideas could look implemented | `docs/roadmap.md`; present catalog/feedback/Sleep/Cardio owners | No approved algorithm or medical rule | Proposed-only section with human validation | Corrected |
## Validation performed in the isolated documentation worktree
`python3 tools/validate_json.py` and
`python3 tools/validate_import_contract.py` passed. With a local
`-Dweb=enabled` Meson build, strict compilation and **114/114** registered
tests passed, including the embedded frontend and package self-containment
checks. A preliminary `-Dweb=disabled` run passed 111/112; its deployment
package test rejected the intentionally absent Web assets as designed. The
changed C/H format gate passed with no changed C/H file. Relative links across
the tracked baseline plus new Markdown files and `git diff --check` passed.
These checks used build artifacts in this worktree and synthetic test data;
they did not exercise a personal device, database or service.
## Boundaries and open evidence
The source can prove format selection, persistence rules and test coverage; it
cannot prove that a particular uninspected phone or service is currently
running that source. The `f4ddcfd` launcher observation identifies a package
path, not a live process hash. The private rollout and Firefox/PDF records are
dated evidence for their stated configurations. Android path reconstruction for
the Web silhouette is source parity evidence, not an Android runtime
screenshot. The local desktop 16-generation and Android eight-generation
admission limits, and the 90-second MTP operation budget, are bounded
configuration, not a guarantee against interruption or proof that transient
storage occupation is always lower than either ceiling.
No code defect was confirmed by this documentation pass. A broad line-by-line
revalidation of every historical review and every external URL is outside the
verified coverage; their current status must not be inferred from this matrix.
No application, database, service, synchronization or personal-data operation
belongs to this documentation branch.

View file

@ -1,5 +1,13 @@
# Roadmap
The Program rescheduling service is deployed in the private daily installation
from `trainlog-program-recalage-v1` at `3a4ec34`. A later compact-calendar
PC/Web package was built from that older source base and omitted the Web body
figure and Sleep sport commits. The corrected 0.1.7 package from `700dfc0`
combines the calendar with those later changes and is installed on the daily
Web launcher. Firefox and a downloaded PDF verified the served result on
2026-10-07; synchronization agents retain their separate Program rollout.
The controlled private rollout is complete for the paired daily installation:
physical libmtp, verified backups, signing continuity, migrations, correlated
ACKs, restart, and idempotent replay passed. Generation mode remains an
@ -31,7 +39,8 @@ estimates.
## Current baseline
Stable v0.1.6 is the release baseline for the v0.1.7 stabilization cycle.
Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
The audited development source has desktop schema v37 and Android schema v34.
Stable v0.1.6 had desktop schema v36 and Android schema v33. 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
@ -90,12 +99,20 @@ protocol evolution, new Android/Web/TUI capabilities, and speculative
optimization are out of scope. Work outside this boundary waits for a later
feature cycle rather than widening 0.1.7.
### Deferred Program rescheduling
### Program rescheduling in the audited development line
Ordered Program rescheduling requires a separate, versioned shared-contract
decision after the actual-date display correction. One explicit user action
must select the starting session, restart date, affected successors, and usable
slots; preview the old and new dates; then durably record one decision. Only
The Web month calendar performs an individual revisioned move by drag and drop
or an accessible date action. It preserves every Program session, including
FACULTATIVE sessions, and offers explicit restoration of legacy ceded rows.
The earlier ordered cascade remains an API compatibility surface but is no
longer offered by the calendar. Completed sessions remain fixed to factual
execution dates. The corrected PC/Web bundle is served by the daily launcher;
its calendar, body figures and Sleep sport PDF were checked in Firefox.
Ordered Program rescheduling is implemented in the audited development line
and deployed in the recorded private Program rollout. The retained cascade API
selects the starting session, restart date, affected successors, and usable
slots; previews the old and new dates; then durably records one decision. Only
unfinished sessions move, in Program position order. Explicitly ceded optional
slots may absorb required sessions without marking optional sessions completed
or removing historical identities. At most one principal Program session per
@ -103,12 +120,12 @@ selected slot is a rule for this action, not a limit on other same-day workouts.
Rest days and Program bounds cannot be silently overridden. Preparations and
in-progress executions require checks before applying the decision.
The result must not cascade again on display, import, replay, or Bluetooth
The result does not cascade again on display, import, replay, or Bluetooth
reconnection. PC and Android must receive the same business state; a Web-only
plan is insufficient. Compatibility requires proof in both supported exchange
directions and across revisions or reexports, including explicit behavior for
older clients unable to represent the decision. Payload acceptance alone does
not establish convergence. This future contract study does not reopen the
not establish convergence. This development contract does not reopen the
Bluetooth transport, Sleep, HR, or general sync invariants. Before any real
application, reread executions and preparations, confirm the restart date and
slot choices, and preview the resulting plan again.
@ -173,7 +190,8 @@ remaining per-installation opt-ins are deployment policy, not the v0.1.3 cursor.
TypeScript and Vite shell with its five client routes, Catppuccin Mocha design
system, permanent Header/Footer, empty-data states and health status. The
Dashboard slices below are complete; this does not authorize implementation of
the remaining route placeholders.
the then-remaining route placeholders; those routes are implemented on the
audited 0.1.7 development line.
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid,
explicit edit mode, drag/resize, keyboard alternative and derived responsive
projections without persisting layout state.
@ -414,6 +432,20 @@ normal capture.
## Later
### Proposed 0.1.8 training assistance (not implemented)
Explore gradual, explained progression or maintaining a load from existing
performance and subjective feedback. Compare exercises and equipment only when
their context is genuinely comparable; account for effort, discomfort and
missing observations. Explore requested or evidenced targeted strengthening,
reasoned exercise diversity and dumbbell use without diagnosing weakness.
Define cautious cardio reference points before any gradual introduction, with
no maximal test or generated prescription from this documentation work. Reuse
the existing medication diary, Sleep and Cardio facts as context without a
second intake workflow or asserted medical causality. A human must validate
any proposed training change before it alters a workout. These are ideas, not
an implementation decision, delivery date, recovery score or clinical rule.
- evolve training analytics only where sufficient real history supports the
metric, without aggregating incomparable loads;
- add body baseline and trend comparisons while keeping direct measurements

81
docs/sleep_diary.md Normal file
View file

@ -0,0 +1,81 @@
# Sleep Diary: capture, review and PDF
This guide describes the Web and Android behavior in the 0.1.7 development
source. The [current state](current_state.md) owns rollout
and validation status; [synchronization exchange](sync_exchange.md) owns the
versioned Sleep companion. Sleep facts and medication intakes are not a medical
interpretation or an automatic training decision.
## Capture and correction
Android's quick actions record bedtime (`Couché`), a waking interval
(`Réveil`), final get-up (`Levé`) and time-stamped medication intakes. An intake
may be recorded during the day or before bedtime. A pre-bed intake creates or
reuses the same durable night/day entry; `Couché` later adds to that entry.
There is no separate medicine log to fill out. Quantity is a count of the
separately stored per-unit dose. Each meaningful edit advances the entry's
causal revision; correction uses the existing entry rather than inventing
another night. The Android owner and draft/quick-publication rules are detailed
in [Android](android.md#sleep-diary-v1-and-active-v2-companion).
The Sleep day is assigned using the local 18:00-to-18:00 boundary. Each Web
agenda row and its heart-rate detail use that night's exact offset and a
24-hour elapsed timeline. A missing factual sleep interval may receive a
clearly labelled presentation estimate when both bedtime and final get-up
exist: bedtime plus 45 minutes through final get-up minus 10 minutes, minus
structured waking overlap for the displayed duration. It does not write a
Sleep fact. No missing heart-rate samples or sleep stages are manufactured.
## Review the agenda
In Analyse → Sommeil, the agenda is newest first. Its single display selector
shows 7, 14, 21, 28 or All **recorded entries** per page; seven is the first-use
default. These are row counts, not complete calendar weeks: seven entries can
span more than seven dates. The choice is browser-local and survives reload;
previous 5/10/15 choices migrate to 7/14/21. The other Analyse sections keep
their own period control. Select a night to inspect and edit that entry and
its measured heart-rate timeline. Paging does not discard the selected night,
editor state, loaded statistics or PDF range.
Saving a Web entry creates a durable local revision. The editor's **Allow
synchronization** action publishes that exact revision; only a current
published revision is eligible for the Sleep synchronization companion. A
subsequent edit needs a new publication action. The editor shows local draft,
ready, synchronized or modified state so this choice remains explicit. Agenda
rows and the PDF do not display publication status because it does not describe
the recorded sleep or affect PDF inclusion. The publication API and durable
state are unchanged.
Blue sport bands come from completed session start/end history, not the
Program plan. A missing or invalid end is shown as a point marker with no
invented duration. The Web agenda omits the redundant sport text list; the PDF
retains a dated sport appendix. Sport does not become a Sleep event or change a
sleep total. If the full read reaches its 3,660-night cap, the Web reports that
completeness cannot be established instead of silently treating a truncated
set as All. Missing data and load errors have explicit states.
## Preview and export
The vector PDF preview and download share an inclusive range of **night start
dates**. That range is initialized from loaded history and is independent of
both the visible agenda page and selected detail night. Sport is read completely
before preview/export. The PDF uses the same 18:00 geometry as Web, prints
factual sport intervals in blue, and lists sessions even when there is no
matching Sleep row. Treatments and remarks wrap by available width and continue
on dated pages instead of truncating silently. Review the chosen range before
export; a PDF is a local copy of potentially sensitive information.
The PDF prints the newest selected night first and orders its sport appendix by
actual session day, newest first, with sessions chronological within each day.
Events and medication intakes within each night keep their chronological order.
A light, printable header shows the
selected period and generation date; alternating pale rows and a restrained
accent distinguish nights. Publication status and global validation warnings
are omitted from the report. This presentation does not alter Sleep facts,
timing, sport attribution, synchronization or the database schema.
The descriptive heart-rate rise/fall overlays compare measured BPM with a
recent reference. They do not infer sleep stages, causes, readiness or medical
conditions. The source-backed projection and evidence limits are summarized in
[current state](current_state.md#versions-and-compatibility) and the
[documentation review](reviews/documentation_deep_review_v1.md).

View file

@ -261,6 +261,28 @@ does not publish to it or let it override an artifact in the new endpoint.
## 3. Artifact table
The following is a navigation matrix for the audited `f4ddcfd` source. A
capability advertisement selects a companion; file presence alone is not an
ACK or proof that every domain converged. The detailed versioned rules below
remain authoritative.
| Domain | Producer → consumer | Format/version | Selection and persistence | ACK/proof and limit |
| --- | --- | --- | --- | --- |
| Completed sessions | Android ↔ desktop | `trainlog-mobile-export` V3; V1/V2 legacy input | Default automatic snapshot; stable IDs in separate SQLite stores | V3 import/replay is idempotent; this is not a full-generation ACK |
| Manual preparations | Desktop → Android | `trainlog-session-preparations` V2 | Full generation on capable peer; revisions, deliveries and withdrawals retained | Correlated generation ACK closes delivery/withdrawal; an active draft is preserved |
| Programs | Desktop → Android | `trainlog-programs` V1 or V2 | Exactly one selected by peer capability; V2 carries current dates, ceded state and revision sequence; desktop owns plan | Correlated ACK; V1-only peer cannot represent a rescheduled live plan, so Program convergence may remain incomplete while other domains proceed |
| Program executions | Android → desktop | `trainlog-program-executions` V1 | Stable in-progress/completed provenance, separate from `trainlog-program` V1 desktop import | Generation validation and ACK; no invented completed session |
| Sleep diary | Android ↔ desktop | `sleep-diary-v2`; frozen V1 readable | Current causal revision and immutable history; v2 carries intake quantity | Generation/ACK plus revision validation; concurrent ambiguous edits can reject |
| Heart rate and Cardio | Android → desktop | Heart Rate V1, session timeline V1, cardio sessions/calibration/guidance V1; separate heart-rate corrections V1 | Factual captures and versioned companions, selected by capabilities | Correlated generation/ACK; no sensor or medical inference on desktop |
| Exercise/equipment context | Both directions | Catalog, aliases, BODY ZONES and equipment companions below | Stable IDs, revision and causal rules apply per domain | A generation ACK does not override a per-domain conflict |
`trainlog-program` V1 is a strict desktop file import defined in
[Program format V1](program_format_v1.md); it is never the synchronized
`trainlog-programs` companion. `/api/v1` is the local Web API, not a JSON
exchange format. Bluetooth, MTP and private Drive move bytes for the same
full-generation business protocol; default V3 compatibility and installation
opt-in remain separate from format capability.
| Direction | File | Format |
| --- | --- | --- |
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
@ -277,6 +299,7 @@ does not publish to it or let it override an artifact in the new endpoint.
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
| PC -> Android | `programs-v2.json` | capability-selected `trainlog-programs` v2 companion: original and current dates, ceded state, ordered revision sequence, and the same deletion tombstones |
| Android -> PC | `program-executions-v1.json` | optional `trainlog-program-executions` v1 companion: stable in-progress/completed provenance |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| Android -> full-generation daemon | `trainlog-sync-full-generation-request-v1.json` | `trainlog-sync-request` v1 |
@ -299,6 +322,33 @@ V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
semantics.
### Programs V2 capability matrix in the audited development line
`trainlog-programs` V2 is a separate strict artifact selected only for a peer
advertising `programs-v2`. Its session adds `position`, nullable `current_for`
and `planning_state=active|ceded`; its Program adds `revision_sequence`.
`planned_for`, stable IDs, source entries and tombstones retain their V1
meaning. One generation contains exactly one Programs version. Android v34
stores the V2 projection transactionally and rejects a lower sequence, a
same-sequence different revision, a later V1 downgrade, or a changed plan for
its currently active Program draft. Execution facts return through the
unchanged Program-executions V1 companion and retain their actual timestamps.
| Desktop state | Android capability | Program result | Other domains |
|---|---|---|---|
| No reschedule | `programs-v1` only | V1 original plan | Normal generation |
| No reschedule | `programs-v2` | V2 projection; V1 is omitted | Normal generation |
| Rescheduled live Program | `programs-v2` | V2 absolute current plan | Normal generation |
| Rescheduled live Program | V1 only, absent or stale V2 | Program artifact omitted; run reports incomplete Program convergence after ACK | Other artifacts still synchronize |
| Program draft started offline after the last V2 ACK | `programs-v2` | Android rejects a changed plan without rewriting its draft; explicit conflict resolution is required | Generation rejection is durable |
The desktop commit requires that the latest generation to each previously
contacted Android peer was an acknowledged V2 Program generation no older than
five minutes. This bounds, but cannot eliminate, a phone-side race: Android's
active-draft check is the final guard. A failed or downgraded peer cannot be
reported as current for the Program domain. The existing generation, request,
ACK, tombstone, transport, Sleep and HR wire contracts are unchanged.
## Optional Program executions companion V1
`trainlog-program-executions` V1 is an Android-to-desktop full-generation
@ -1084,7 +1134,7 @@ named by that validated reference. It does not recursively mirror retained
Android generations, desktop publications, or unrelated exchange history.
Local correlation is checked every 50 ms without USB work; complete MTP pulls
are throttled to a three-second cadence. Each MTP operation has its own
30-second ceiling while the conversation retains its configured global
90-second ceiling while the conversation retains its configured global
deadline. Exhausting the global deadline while waiting for a correlated
reference reports `timeout waiting for correlated android-generation-v1.json`;
only an adapter that exceeds its independent operation budget reports

View file

@ -51,26 +51,29 @@ generalizes pairing to another phone or authorizes an unconfigured desktop.
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
foreign-key checks.
4. V3 is not an Android backup. Current Android has a complete verified backup
container. For the installed schema-v17 release, build the pinned bridge
candidate, verify signing/version continuity separately, install only with
explicit approval, create and verify the user backup, and retain it before
installing current Android. None of those device/user operations occurred in
this software mission.
4. V3 is not an Android backup. At the original September 2026 rollout, the
installed schema-v17 phone required a pinned bridge candidate, verified
signing/version continuity, and a complete retained user backup before the
newer Android package. That is historical procedure evidence, not a claim
about the currently installed phone schema or an instruction to repeat the
bridge. A new rollout must inspect its actual versions and backups first.
5. Replacing a binary does not roll back a schema. Before any exchange, a
mutually compatible captured pair may be restored. After causal writes or
ACKs, never restore only one peer: stop synchronization, preserve both stores
and all artifacts, and use an explicit paired recovery plan.
6. The eight-generation-per-peer ceiling is an admission gate. Exhaustion must
preserve pending generations, tombstones, finalizations and ACK ledgers; it
must never trigger hidden eviction.
6. Per-peer retained-generation ceilings are admission gates: the audited
desktop `sync_generation_exchange.py` permits 16 active outgoing entries;
Android `SyncGenerationService` permits eight. Exhaustion preserves pending
generations, tombstones, finalizations and ACK ledgers; it never triggers
hidden eviction. These counts are not a bound on all transient staging or
transport objects.
The grouped rollout approval covered the signed APK, desktop bundle, verified
user backups, coordinated service quiescence, pairing, synthetic smoke and
non-destructive hardware smoke. Those steps are complete for the recorded
private installation. Drive remains separate and untested.
The latest software-only candidate set was rebuilt under the private root
The software-only candidate recorded for the original rollout was rebuilt under the private root
`/home/fy59/.cache/trainlog/sync-rollout-candidate-v1.NEoMDx` from source commit
`dd8c91a946a9c836965818d8cc35b8f8bce74658`. Its desktop inventory records every
packaged file digest plus schemas, protocols, entry points and runtime
@ -84,7 +87,7 @@ synthetic schema-v17 backup SHA-256 is
`06eb3999c6f3ce2fd8f551b38727a0a67fe6b48dde4c3244b3d50357afa7e6a3`.
These are unsigned development evidence, not deployment authorization.
## Compatible set
## Compatible set for the original rollout
- desktop binary containing schema v21, the Web API and the generation worker;
- `sync_generation_exchange.py` and all matching transaction-neutral codecs;
@ -110,11 +113,16 @@ These are unsigned development evidence, not deployment authorization.
Do not delete an unacknowledged generation, the last acknowledged generation,
its previous valid generation, or causal evidence. An ACK timeout is unresolved,
not rollback. Eight retained outgoing generations exhaust admission and require
not rollback. Sixteen retained outgoing generations exhaust admission and require
operator diagnosis; this tranche adds no tombstone or evidence garbage
collection. A migrated database is recovered from its verified backup, never by
assuming an older binary reverses its migration.
The audited MTP peer worker has a 90-second per-operation budget, further
limited by the remaining conversation deadline. It is a timeout bound, not a
success guarantee or proof that an interruption cause has disappeared.
The recorded private installation completed physical MTP, service coordination
and APK/desktop deployment. Actual Drive configuration remains untested; the
eight-generation admission bound and foreground-phone requirement remain.
Android eight-generation admission bound and foreground-phone requirement
remain; the audited desktop limit is 16.

View file

@ -1,5 +1,17 @@
# Tests and validation
## Reading the evidence
Counts below belong to the stated checkpoint, source revision and build
configuration. They are historical evidence, not a claim that the present
documentation checkout ran those tests. A native build with `-Dweb=enabled`
registers embedded Web asset tests in Meson; `npm test` separately runs Vitest
against Web source. A default `web=auto` build can omit embedded assets, so a
green source test alone does not validate the packaged Web application.
Skipped Android fixture tests, unrun instrumented tests and physical Bluetooth,
MTP or Drive evidence are distinct outcomes. The primary personal phone is
reserved for authorized manual smoke checks, not host automation.
## Sleep V2 concurrent-edit selection in 0.1.7
Synthetic Desktop and Android regressions cover both edit-time orderings,
@ -397,6 +409,29 @@ subset summaries cannot inherit facts from unselected nights. The same real
Firefox scenario exports a seven-night French PDF from the embedded production
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
with `pdftoppm` for retained visual inspection.
The 0.1.7 PDF polish tests also assert newest-first night and sport day order,
chronological sessions within each day, unchanged chronological intervals and
medication intakes within a night, 7/14/21/28-night pagination, and absence of
global validation warnings. Web
tests keep the exact-revision publication action and editing while excluding
publication labels from agenda rows.
The completed-sport Sleep overlay is covered by C read-only history
intersection/pagination checks, Web pagination and failure-state tests, and
pure geometry tests for 05:00 placement, midnight, 18:00 clipping, missing and
invalid ends, and the Europe/Paris fall offset. PDF tests assert vector blue
bands, black outlines, factual labels, and unchanged Sleep totals. Synthetic
Firefox validation uses an isolated schema-v37 database and the embedded
`-Dweb=enabled` binary, then inspects a downloaded PDF rendered by `pdftoppm`.
Synthetic French and English reports of 7, 14, 21, and 30 nights are rasterized
for page, margin, and monochrome inspection. No personal health artifact is
stored in the repository.
The private PC/Web deployment was checked separately after the rollout switch:
the running executable and the served JavaScript/CSS hashes matched the
`e2c07f1` bundle. Read-only Firefox inspection found 9 blue segments across
11 existing Sleep rows; a downloaded three-page PDF contained the same sampled
factual start time and a printable sport listing. Its private images and PDF
remain outside the repository.
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
field and Vitest fixtures confined to test sources. Coverage proves the 90-day

View file

@ -51,5 +51,7 @@ Create, update and retirement first commit only to the desktop database. They
do not call synchronization and do not require a phone. The next user-triggered
normal synchronization reuses the existing PC catalogue, exercise profile
state, BODY ZONES, equipment companions and causal deletion artifacts through
the existing USB-priority engine with Drive as mirror/fallback. No Web
the configured generation engine (Bluetooth primary with direct MTP recovery
on the paired installation, or the separate USB/Drive workflow). Without that
opt-in, the default V3 path retains its documented compatibility limits. No Web
Exercises artifact or protocol version was introduced.

View file

@ -84,7 +84,8 @@ Inactive execution drafts and completed sessions use the existing causal
operation/state boundary. Active or stale drafts conflict, and completed-session
deletion records a finalization so older snapshots cannot recreate the session.
Programs are planning data owned by desktop schema v26. The Programs subtab
Programs are planning data owned by desktop SQLite (introduced in v25 and
extended through v37). The Programs subtab
uses responsive cards and a timeline detail view backed by real imported
definitions, sessions, usage, and provenance data. Import first runs the
same strict Core validator in preview mode, then commits only after explicit

View file

@ -11,6 +11,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import export_programs
import export_programs_v2
SCHEMA = """
@ -79,6 +80,41 @@ class ProgramsExchangeTest(unittest.TestCase):
"requested_at": "2026-09-18T12:00:00Z",
}], payload["deletions"])
def test_v2_carries_current_date_and_ceded_state_without_changing_v1(self):
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE program_session_planning(
program_session_id TEXT PRIMARY KEY,current_for TEXT,
state TEXT,operation_id TEXT,updated_at TEXT);
CREATE TABLE program_revision_sequences(
program_id TEXT PRIMARY KEY,revision_sequence INTEGER NOT NULL);
INSERT INTO program_revision_sequences VALUES('pg_live',1);
INSERT INTO program_session_planning VALUES(
'pgs_live','2026-10-01','active','op_fixture','2026-09-30T12:00:00Z');
PRAGMA user_version=37;
""")
db.commit()
initial = export_programs.export_programs(self.database)
current = export_programs_v2.export_programs_v2(self.database)
self.assertEqual(1, initial["version"])
self.assertEqual(2, current["version"])
original = initial["programs"][0]["sessions"][0]
planned = current["programs"][0]["sessions"][0]
self.assertIsNone(original["planned_for"])
self.assertEqual(set(original), {
"program_session_id", "title", "session_type", "planned_for", "note", "occurrences"
})
self.assertEqual("2026-10-01", planned["current_for"])
self.assertEqual(1, current["programs"][0]["revision_sequence"])
self.assertEqual("active", planned["planning_state"])
self.assertEqual(0, planned["position"])
with closing(sqlite3.connect(self.database)) as db:
db.execute("UPDATE program_session_planning SET current_for=NULL,state='ceded'")
db.commit()
ceded = export_programs_v2.export_programs_v2(self.database)
self.assertIsNone(ceded["programs"][0]["sessions"][0]["current_for"])
self.assertEqual("ceded", ceded["programs"][0]["sessions"][0]["planning_state"])
if __name__ == "__main__":
unittest.main()

View file

@ -77,10 +77,11 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.7")
self.assertEqual(inventory["desktop_schema"], 36)
self.assertEqual(inventory["android_schema"], 32)
self.assertEqual(inventory["desktop_schema"], 37)
self.assertEqual(inventory["android_schema"], 34)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])
self.assertIn("programs-v2", inventory["protocols"])
self.assertIn("program-executions-v1", inventory["protocols"])
self.assertIn("sleep-diary-v2", inventory["protocols"])
self.assertIn("cardio-sessions-v1", inventory["protocols"])

View file

@ -128,21 +128,25 @@ class SyncErrorClassificationTest(unittest.TestCase):
)
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
operation_budget = sync_peer_worker.MTP_OPERATION_TIMEOUT_SECONDS
timeout = subprocess.TimeoutExpired(
["adapter", "push"], operation_budget,
)
with tempfile.TemporaryDirectory() as directory:
transport = Path(directory) / "transport"
transport.mkdir()
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
with self.assertRaisesRegex(
RuntimeError,
r"^transport_timeout: MTP push did not finish within 30 seconds$",
rf"^transport_timeout: MTP push did not finish within "
rf"{operation_budget:g} seconds$",
) as caught:
sync_peer_worker.run_adapter(
Path("/adapter"),
"push",
"peer_00000000-0000-4000-8000-000000000001",
transport,
sync_peer_worker.time.monotonic() + 60,
sync_peer_worker.time.monotonic() + operation_budget + 10,
)
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")

View file

@ -71,6 +71,58 @@ class GenerationTest(unittest.TestCase):
with closing(sqlite3.connect(destination)) as reopened:
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
def test_rescheduled_program_requires_v2_capability_before_publication(self):
with closing(sqlite3.connect(self.database)) as db:
db.executescript("""
CREATE TABLE program_session_planning(
program_session_id TEXT PRIMARY KEY,current_for TEXT,state TEXT);
CREATE TABLE programs(program_id TEXT PRIMARY KEY,deleted_at TEXT);
CREATE TABLE program_sessions(
program_session_id TEXT PRIMARY KEY,program_id TEXT);
INSERT INTO programs VALUES('pg_fixture',NULL);
INSERT INTO program_sessions VALUES('pgs_fixture','pg_fixture');
INSERT INTO program_session_planning VALUES('pgs_fixture','2026-10-01','active');
PRAGMA user_version=37;
""")
db.commit()
artifacts = (
("history", "trainlog-mobile-export", 4, "history.json", True,
"controlled.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
"export_programs_v2.py", ()),
)
def exporter(tool, _extra, output, _snapshot):
if tool == "controlled.py":
output.write_text(json.dumps({"format": "trainlog-mobile-export", "version": 4}))
return
output.write_text(json.dumps({"format": "trainlog-programs",
"version": 2 if tool.endswith("_v2.py") else 1,
"programs": [], "deletions": []}))
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
mock.patch.object(generation, "run_export", exporter):
with self.assertRaisesRegex(generation.GenerationError, "Programs V2 capability"):
generation.capture_desktop(self.database, self.root / "legacy", PEER_B, RUN, GEN)
self.assertFalse((self.root / "legacy" / "staging" / GEN).exists())
legacy_stage, legacy_manifest, _ = generation.capture_desktop(
self.database, self.root / "legacy-domain-only", PEER_B, RUN, GEN,
set(), True,
)
self.assertFalse((legacy_stage / "programs-v1.json").exists())
self.assertEqual(["history"], [a["logical_name"] for a in legacy_manifest["artifacts"]])
stage, manifest, _ = generation.capture_desktop(
self.database, self.root / "modern", PEER_B, RUN,
"gen_55555555-5555-4555-8555-555555555555",
{"programs-v2"},
)
self.assertTrue((stage / "programs-v2.json").is_file())
self.assertFalse((stage / "programs-v1.json").exists())
self.assertEqual(["history", "programs-v2"],
[a["logical_name"] for a in manifest["artifacts"]])
def test_archived_acknowledged_fork_is_not_an_active_parent_tip(self):
active = "gen_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
archived = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"

View file

@ -58,7 +58,8 @@ class SyncPeerWorkerTest(unittest.TestCase):
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 100.0, clock=lambda: 50.0,
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
clock=lambda: 50.0,
)
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
@ -112,7 +113,8 @@ class SyncPeerWorkerTest(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 100.0, clock=lambda: 50.0,
self.transport, 50.0 + worker.MTP_OPERATION_TIMEOUT_SECONDS + 10.0,
clock=lambda: 50.0,
)
def test_delayed_correlated_generation_survives_multiple_polls(self):

View file

@ -271,8 +271,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
fail("desktop schema v33-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36, 37):
fail("desktop schema v33-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -395,8 +395,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
fail("desktop schema v34-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36, 37):
fail("desktop schema v34-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 37))
supported_versions = tuple(range(11, 38))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v36 requis")
raise ValueError("schema desktop v11-v37 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

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

View file

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

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 37)
supported_versions = range(11, 38)
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-v36 requis (v10 accepté pour export V2 explicite)")
raise ValueError("schema desktop v11-v37 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")

View file

@ -16,7 +16,7 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ValueError("desktop schema v26 through v32 required")
programs = []
for row in db.execute(

View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Export the versioned desktop-owned Program planning projection."""
import argparse
import json
from contextlib import closing
from pathlib import Path
from export_programs import export_programs
from trainlog_sqlite import connect_database
def export_programs_v2(database: Path) -> dict:
# WHY: V1's strict session fields are frozen. V2 carries absolute current
# dates and ceded-slot state without changing the imported source date.
value = export_programs(database)
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 37:
raise ValueError("desktop schema v37 required for Programs V2")
for program in value["programs"]:
sequence = db.execute(
"SELECT revision_sequence FROM program_revision_sequences WHERE program_id=?",
(program["program_id"],),
).fetchone()
program["revision_sequence"] = sequence[0] if sequence is not None else 0
planning = {
row[0]: (row[1], row[2])
for row in db.execute(
"SELECT p.program_session_id,p.current_for,p.state "
"FROM program_session_planning p JOIN program_sessions s "
"ON s.program_session_id=p.program_session_id WHERE s.program_id=?",
(program["program_id"],),
)
}
for position, session in enumerate(program["sessions"]):
current, state = planning.get(
session["program_session_id"], (session["planned_for"], "active")
)
session["position"] = position
session["current_for"] = current
session["planning_state"] = state
value["version"] = 2
return value
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
args.output.write_text(
json.dumps(export_programs_v2(args.database), ensure_ascii=False,
sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
if __name__ == "__main__":
main()

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26 through v32 required")
deliveries = []

View file

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

View file

@ -61,8 +61,8 @@ def split_target(value: str) -> tuple[str, str]:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in range(27, 37):
raise CorrectionError(f"heart-rate correction requires schema v27-v36, found v{version}")
if version not in range(27, 38):
raise CorrectionError(f"heart-rate correction requires schema v27-v37, found v{version}")
def _table_exists(db: sqlite3.Connection, table: str) -> bool:

View file

@ -282,8 +282,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v30-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 37)
supported_versions = range(18, 38)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v36 requis")
raise ImportFailure("schema desktop v18-v37 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 37)
supported_versions = range(8, 38)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v36 requis")
fail("schema desktop v8 à v37 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 37)
supported_versions = range(8, 38)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v36 requis")
fail("schema desktop v8 à v37 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 37)
supported_versions = range(12, 38)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v36 requis")
fail("schema desktop v12-v37 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")

View file

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

View file

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

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 37):
if version not in range(5, 38):
raise ImportFailure(
f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
f"base desktop schema v5 à v37 attendue, version trouvée: {version}"
)
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v37")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

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

View file

@ -53,6 +53,7 @@ RUNTIME_TOOLS = [
"post_sync_ai_drive.py",
"export_session_preparations.py",
"export_programs.py",
"export_programs_v2.py",
"program_execution_exchange.py",
]
@ -81,6 +82,14 @@ def main() -> int:
raise RuntimeError("compiled generation MTP adapter is missing")
if not sync_once.is_file():
raise RuntimeError("compiled legacy synchronization helper is missing")
# WHY: web=auto can build a native executable without any embedded UI.
# CONTRACT: a rollout bundle must contain the frontend it will serve.
build_options = json.loads(subprocess.check_output(
["meson", "introspect", "--buildoptions", str(args.build)], text=True
))
web = next((option["value"] for option in build_options if option["name"] == "web"), None)
if web != "enabled":
raise RuntimeError("candidate requires an explicitly embedded Web frontend")
(args.output / "bin").mkdir(parents=True)
(args.output / "libexec").mkdir(parents=True)
(args.output / "tools").mkdir(parents=True)
@ -120,8 +129,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.7",
"desktop_schema": 36,
"android_schema": 32,
"desktop_schema": 37,
"android_schema": 34,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -135,6 +144,7 @@ def main() -> int:
"session-preparations-v2",
"ai-session-drafts-v2",
"programs-v1",
"programs-v2",
"program-executions-v1",
"sleep-diary-v2",
"cardio-sessions-v1",

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v27 through v32 required")
added = 0
advanced = 0

View file

@ -184,8 +184,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
fail("desktop schema v31-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v31-v37 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -637,8 +637,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36, 37):
fail("desktop schema v30-v37 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")

View file

@ -51,7 +51,11 @@ MAX_GENERATION = 256 * 1024 * 1024
MAX_NAME = 64
MAX_PATH = 240
MAX_DIAGNOSTIC = 1024
MAX_RETAINED_PER_PEER = 8
# WHY: interrupted MTP publications can leave immutable generations awaiting
# an ACK alongside the two acknowledged lineage members kept for recovery.
# CONTRACT: admission remains bounded and never erases unresolved generations.
# INVARIANT: captured, published and waiting generations still consume a slot.
MAX_RETAINED_PER_PEER = 16
ID = re.compile(r"^(?:gen|peer|sy)_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
HEX = re.compile(r"^[0-9a-f]{64}$")
@ -82,6 +86,8 @@ ARTIFACTS = (
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
("programs-v2", "trainlog-programs", 2, "programs-v2.json", False,
"export_programs_v2.py", ()),
("sleep-diary", "trainlog-sleep-diary", 2, "sleep-diary-v2.json", False,
"sleep_diary_exchange.py", ("export",)),
)
@ -221,9 +227,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 through v36 required")
raise GenerationError("desktop schema v24 through v37 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -366,7 +372,9 @@ def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path)
def capture_desktop(database: Path, owned_root: Path, consumer: str,
run_id: str | None = None, generation_id: str | None = None):
run_id: str | None = None, generation_id: str | None = None,
peer_capabilities: set[str] | None = None,
allow_program_skip: bool = False):
if not valid_id(consumer, "peer"):
raise GenerationError("invalid expected consumer")
generation = generation_id or "gen_" + str(uuid.uuid4())
@ -405,9 +413,35 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
producer = peer_identity(source, "desktop")
source.commit()
source.backup(target)
with closing(connect_database(snapshot)) as planning_db:
has_planning_table = planning_db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' "
"AND name='program_session_planning'"
).fetchone() is not None
has_reschedule = has_planning_table and planning_db.execute(
"SELECT 1 FROM program_session_planning plan "
"JOIN program_sessions session ON session.program_session_id=plan.program_session_id "
"JOIN programs program ON program.program_id=session.program_id "
"WHERE program.deleted_at IS NULL LIMIT 1"
).fetchone() is not None
supports_v2 = peer_capabilities is not None and "programs-v2" in peer_capabilities
# CONTRACT: one generation contains exactly one Program revision per
# identity. A V1-only peer cannot preserve a changed current schedule.
# INVARIANT: reject before publication rather than claiming convergence.
skip_programs = has_reschedule and not supports_v2
if skip_programs and not allow_program_skip:
raise GenerationError("Programs V2 capability required for rescheduled programs")
if supports_v2 and not has_planning_table:
raise GenerationError("Programs V2 requires desktop schema v37")
descriptors = []
total = 0
for logical, fmt, version, filename, required, tool, extra in ARTIFACTS:
if skip_programs and logical in ("programs-v1", "programs-v2"):
continue
if logical == "programs-v1" and supports_v2:
continue
if logical == "programs-v2" and not supports_v2:
continue
output = stage / filename
run_export(tool, extra, output, snapshot)
size, checksum = digest_file(output)
@ -521,7 +555,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1))
programs_path = stage / "programs-v1.json"
programs_path = stage / ("programs-v2.json" if supports_v2 else "programs-v1.json")
if programs_path.is_file():
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
for deletion in captured_programs["deletions"]:

View file

@ -37,7 +37,11 @@ CAPS = {
ACK_RECOVERY_CAP = "generation-ack-recovery-v1"
MTP_OPERATION_TIMEOUT_SECONDS = 30.0
# WHY: a verified personal phone can need more than 30 seconds to publish an
# immutable Programs generation over MTP. CONTRACT: this bounds each adapter
# call independently of the configured overall conversation deadline.
# INVARIANT: an adapter overrun remains an ambiguous transport failure.
MTP_OPERATION_TIMEOUT_SECONDS = 90.0
MTP_POLL_INTERVAL_SECONDS = 3.0
@ -827,8 +831,13 @@ def main() -> int:
# diagnostics to the separately bounded stderr channel.
with redirect_stdout(sys.stderr):
stage, manifest, digest = generation.capture_desktop(
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id
args.database, args.owned_root, peer["peer_id"], args.run_id, outgoing_id,
set(peer["capabilities"]), True,
)
program_domain_incompatible = not any(
artifact["logical_name"] in ("programs-v1", "programs-v2")
for artifact in manifest["artifacts"]
)
del stage
published = generation.publish(
args.database, outgoing_id, args.transport_root / "desktop-objects"
@ -926,6 +935,14 @@ def main() -> int:
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
raise RuntimeError("generation evidence did not corroborate completion")
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
if program_domain_incompatible:
# WHY: the old peer cannot retain the new absolute schedule, but its
# unrelated domains can still converge through the same generation.
# CONTRACT: never report whole-run success while its Program view is stale.
raise RuntimeError(
"Programs V2 capability required: other domains synchronized, "
"Program planning not published to this peer"
)
ai_post_sync = publish_ai_export_to_android(args, deadline)
report = {
"format": "trainlog-sync-worker-report",

View file

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

View file

@ -63,4 +63,17 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size);
/* WHY: original Program dates are immutable source evidence. CONTRACT: the
* preview is read only; commit requires its exact fact/choice fingerprint,
* current Program revision and a stable operation ID. INVARIANT: one commit
* changes absolute planning dates in one transaction, never history rows. */
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
const char *program_id,
const char *operation_id,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size);
#endif

View file

@ -41,6 +41,19 @@ TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
char **output_json,
size_t *output_size);
/* WHY: the Sleep agenda needs factual completed sessions without fetching one
* detail per row. CONTRACT: start/end are bounded ISO instants; intervals
* intersect [start,end), and a session with no valid end is a point at start.
* Pages are ordered by factual start and stable ID. No state is mutated.
* INVARIANT: the caller owns output_json and must paginate until more=false. */
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
const char *start,
const char *end,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size);
/* CONTRACT: kind is one of preparation, proposal, draft, or history. The
* returned detail is a factual snapshot and performs no lifecycle transition. */
TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database,

View file

@ -239,6 +239,7 @@ trainlog_core_sources = files(
'src/web_sessions.c',
'src/web_session_deletions.c',
'src/web_programs.c',
'src/web_program_reschedule.c',
'src/web_sleep.c',
'src/web_exercises.c',
'src/web_equipment.c',

View file

@ -1578,6 +1578,39 @@ static const char *const MIGRATE_V35_TO_V36_SQL =
"immutable');END;"
"PRAGMA user_version=36;COMMIT;";
/* WHY: a Program's imported date is evidence, while an explicit reschedule
* owns its current target and an optional relinquishment of a future slot.
* CONTRACT: absent planning rows retain the original date and active state;
* operation identities bind replay to an exact request digest. INVARIANT:
* migration changes no execution, preparation, source date, or history row. */
static const char *const MIGRATE_V36_TO_V37_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS program_session_planning("
"program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) "
"ON DELETE RESTRICT,"
"current_for TEXT,"
"state TEXT NOT NULL CHECK(state IN('active','ceded')),"
"operation_id TEXT NOT NULL,updated_at TEXT NOT NULL,"
"CHECK((state='active' AND current_for IS NOT NULL) OR "
"(state='ceded' AND current_for IS NULL)));"
"CREATE TABLE IF NOT EXISTS program_reschedule_operations("
"operation_id TEXT PRIMARY KEY,"
"program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT,"
"request_sha256 TEXT NOT NULL CHECK(length(request_sha256)=64),"
"expected_revision TEXT NOT NULL,"
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
"CREATE INDEX IF NOT EXISTS program_reschedule_operations_program "
"ON program_reschedule_operations(program_id,created_at);"
"CREATE TABLE IF NOT EXISTS program_revision_sequences("
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
"revision_sequence INTEGER NOT NULL CHECK(revision_sequence>=1));"
"CREATE TRIGGER IF NOT EXISTS programs_revision_sequence AFTER UPDATE OF revision_id "
"ON programs WHEN OLD.revision_id<>NEW.revision_id BEGIN "
"INSERT INTO program_revision_sequences(program_id,revision_sequence) "
"VALUES(NEW.program_id,1) ON CONFLICT(program_id) DO UPDATE SET "
"revision_sequence=revision_sequence+1;END;"
"PRAGMA user_version=37;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2413,7 +2446,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
version == 36) {
version == 36 || version == 37) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2585,6 +2618,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 36) {
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 37) {
status = execute_sql(database, MIGRATE_V36_TO_V37_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2604,7 +2640,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 v36" : "migrate database to schema v36",
version == 0 ? "create schema v37" : "migrate database to schema v37",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database,
static bool
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
static const char SQL[] =
/* WHY: imported dates are historical after Program rescheduling.
* CONTRACT: select one pending, non-ceded session with its current date.
* INVARIANT: a present planning row with NULL current_for stays undated. */
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
"program_session_id) AS rank FROM eligible) "
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
"(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1),"
"(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN "
"program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE "
"ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') "
"ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position "
"LIMIT 1),"
"(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions "
"pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
"next.title,next.program_session_id,next.current_for "
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
"program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN "
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
"DESC "
"DESC,p.program_id "
"LIMIT 1";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *program = yyjson_mut_obj(document);

View file

@ -0,0 +1,660 @@
#include "trainlog/web_programs.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/evp.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.h"
#include "trainlog/id.h"
typedef struct PlanningRow {
const char *id;
const char *original;
const char *current;
const char *execution;
const char *planning;
const char *assigned;
bool cede;
} PlanningRow;
static TrainlogStatus
planning_error(const char *reason, TrainlogStatus status, char **output, size_t *size) {
size_t length = strlen(reason) + 16U;
*output = malloc(length);
if (*output == NULL) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
*size = (size_t)snprintf(*output, length, "{\"error\":\"%s\"}", reason);
return status;
}
static bool planning_digest(
const char *first, size_t first_size, const char *second, size_t second_size, char output[65]) {
EVP_MD_CTX *context = EVP_MD_CTX_new();
unsigned char digest[32];
unsigned int length = 0U;
bool okay = context != NULL && EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
EVP_DigestUpdate(context, first, first_size) == 1 &&
EVP_DigestUpdate(context, second, second_size) == 1 &&
EVP_DigestFinal_ex(context, digest, &length) == 1 && length == 32U;
EVP_MD_CTX_free(context);
if (!okay) {
return false;
}
for (size_t index = 0U; index < 32U; ++index) {
(void)snprintf(output + index * 2U, 3U, "%02x", digest[index]);
}
output[64] = '\0';
return true;
}
static bool valid_civil_date(const char *value) {
unsigned year, month, day;
int days;
if (value == NULL || strlen(value) != 10U || value[4] != '-' || value[7] != '-' ||
sscanf(value, "%4u-%2u-%2u", &year, &month, &day) != 3 || year < 1900U || year > 9999U ||
month < 1U || month > 12U) {
return false;
}
for (size_t index = 0U; index < 10U; ++index) {
if (index != 4U && index != 7U && (value[index] < '0' || value[index] > '9')) {
return false;
}
}
static const int MONTH_DAYS[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
days = MONTH_DAYS[month - 1U];
if (month == 2U && (year % 4U == 0U && (year % 100U != 0U || year % 400U == 0U))) {
days = 29;
}
return day >= 1U && day <= (unsigned)days;
}
static bool
add_nullable(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, const char *value) {
return value == NULL ? yyjson_mut_obj_add_null(document, object, key)
: yyjson_mut_obj_add_strcpy(document, object, key, value);
}
static bool request_valid(yyjson_val *root, bool commit) {
yyjson_obj_iter iterator;
yyjson_val *key;
size_t count = 0U;
if (!yyjson_is_obj(root) || yyjson_obj_size(root) != (commit ? 6U : 5U)) {
return false;
}
iterator = yyjson_obj_iter_with(root);
while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
const char *name = yyjson_get_str(key);
if (strcmp(name, "start_session_id") != 0 && strcmp(name, "through_session_id") != 0 &&
strcmp(name, "start_date") != 0 && strcmp(name, "ceded_session_ids") != 0 &&
strcmp(name, "expected_revision") != 0 &&
!(commit && strcmp(name, "preview_sha256") == 0)) {
return false;
}
++count;
}
return count == (commit ? 6U : 5U) && yyjson_is_str(yyjson_obj_get(root, "start_session_id")) &&
yyjson_is_str(yyjson_obj_get(root, "through_session_id")) &&
yyjson_is_str(yyjson_obj_get(root, "expected_revision")) &&
yyjson_get_len(yyjson_obj_get(root, "start_session_id")) <= 128U &&
yyjson_get_len(yyjson_obj_get(root, "through_session_id")) <= 128U &&
yyjson_get_len(yyjson_obj_get(root, "expected_revision")) <= 128U &&
valid_civil_date(yyjson_get_str(yyjson_obj_get(root, "start_date"))) &&
yyjson_is_arr(yyjson_obj_get(root, "ceded_session_ids")) &&
yyjson_arr_size(yyjson_obj_get(root, "ceded_session_ids")) <= 64U &&
(!commit || (yyjson_is_str(yyjson_obj_get(root, "preview_sha256")) &&
yyjson_get_len(yyjson_obj_get(root, "preview_sha256")) == 64U));
}
static bool has_preparation(TrainlogDatabase *database, const char *session_id) {
sqlite3_stmt *statement = NULL;
bool present = true;
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM session_preparations WHERE "
"source_program_session_id=? AND withdrawn_at IS NULL LIMIT 1",
-1,
&statement,
NULL) == SQLITE_OK &&
sqlite3_bind_text(statement, 1, session_id, -1, SQLITE_TRANSIENT) == SQLITE_OK) {
present = sqlite3_step(statement) != SQLITE_DONE;
}
(void)sqlite3_finalize(statement);
return present;
}
static TrainlogStatus peer_planning_fresh(TrainlogDatabase *database) {
static const char SQL[] =
"SELECT EXISTS(SELECT 1 FROM "
"(SELECT DISTINCT consumer_peer_id FROM sync_generations "
"WHERE producer_kind='desktop') peer WHERE "
"NOT EXISTS(SELECT 1 FROM sync_generations generation "
"JOIN sync_generation_artifacts artifact "
"ON artifact.generation_id=generation.generation_id "
"WHERE generation.generation_id=(SELECT latest.generation_id "
"FROM sync_generations latest WHERE latest.consumer_peer_id=peer.consumer_peer_id "
"AND latest.producer_kind='desktop' "
"ORDER BY julianday(latest.generated_at) DESC,latest.generation_id DESC LIMIT 1) "
"AND generation.status='acknowledged' AND artifact.logical_name='programs-v2' "
"AND julianday(generation.acknowledged_at)>=julianday('now','-5 minutes')))";
sqlite3_stmt *statement = NULL;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
status =
sqlite3_column_int(statement, 0) == 0 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT;
}
(void)sqlite3_finalize(statement);
return status;
}
static TrainlogStatus build_preview(TrainlogDatabase *database,
const char *program_id,
yyjson_val *request,
char **output,
size_t *output_size) {
char *detail = NULL;
size_t detail_size = 0U;
yyjson_doc *source = NULL;
yyjson_mut_doc *response = NULL;
yyjson_val *program;
yyjson_val *sessions;
yyjson_val *ceded;
yyjson_mut_val *root;
yyjson_mut_val *moves;
PlanningRow rows[64] = {0};
const char *slots[64] = {0};
size_t first = 64U;
size_t last = 64U;
size_t count;
size_t slot_count = 0U;
size_t next_slot = 0U;
char fingerprint[65];
char *choices = NULL;
size_t choices_size = 0U;
TrainlogStatus status =
trainlog_web_programs_detail_json(database, program_id, &detail, &detail_size);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
source = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
program = source == NULL ? NULL : yyjson_doc_get_root(source);
sessions = program == NULL ? NULL : yyjson_obj_get(program, "sessions");
ceded = yyjson_obj_get(request, "ceded_session_ids");
count = yyjson_arr_size(sessions);
if (program == NULL || count > 64U ||
!yyjson_equals_str(yyjson_obj_get(program, "state"), "active") ||
!yyjson_equals_str(yyjson_obj_get(program, "revision_id"),
yyjson_get_str(yyjson_obj_get(request, "expected_revision")))) {
status = planning_error(
"stale_or_ineligible_program", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
for (size_t index = 0U; index < count; ++index) {
yyjson_val *session = yyjson_arr_get(sessions, index);
rows[index].id = yyjson_get_str(yyjson_obj_get(session, "program_session_id"));
rows[index].original = yyjson_get_str(yyjson_obj_get(session, "planned_for"));
rows[index].current = yyjson_get_str(yyjson_obj_get(session, "current_for"));
rows[index].execution = yyjson_get_str(yyjson_obj_get(session, "execution_state"));
rows[index].planning = yyjson_get_str(yyjson_obj_get(session, "planning_state"));
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "start_session_id"))) ==
0) {
first = index;
}
if (strcmp(rows[index].id, yyjson_get_str(yyjson_obj_get(request, "through_session_id"))) ==
0) {
last = index;
}
}
if (first >= count || last >= count || first > last ||
(first != last && (!yyjson_is_str(yyjson_obj_get(program, "end_date")) ||
strcmp(yyjson_get_str(yyjson_obj_get(request, "start_date")),
yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0))) {
status = planning_error(
"invalid_selection", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
yyjson_val *id = yyjson_arr_get(ceded, index);
bool matched = false;
if (!yyjson_is_str(id) || yyjson_get_len(id) > 128U) {
status = planning_error(
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
for (size_t candidate = first; candidate <= last; ++candidate) {
if (strcmp(rows[candidate].id, yyjson_get_str(id)) == 0 && !rows[candidate].cede) {
rows[candidate].cede = true;
matched = true;
break;
}
}
if (!matched) {
status = planning_error(
"invalid_ceded_identity", TRAINLOG_STATUS_INVALID_ARGUMENT, output, output_size);
goto done;
}
}
/* WHY: calendar moves target one identity, including a formerly ceded
* identity. CONTRACT: its requested civil day is the new current date,
* even when another session already occupies that day. INVARIANT: no
* other planning row is included in the resulting mutation. */
if (first == last && yyjson_arr_size(ceded) == 0U) {
PlanningRow *row = &rows[first];
if (strcmp(row->execution, "todo") != 0 || has_preparation(database, row->id)) {
status = planning_error(
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
row->assigned = yyjson_get_str(yyjson_obj_get(request, "start_date"));
goto assigned;
}
for (size_t index = first; index <= last; ++index) {
if (strcmp(rows[index].execution, "todo") != 0 ||
has_preparation(database, rows[index].id) || rows[index].original == NULL ||
!valid_civil_date(rows[index].original)) {
status = planning_error(
"prepared_or_executed_session", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
if (strcmp(rows[index].planning, "ceded") == 0 && !rows[index].cede) {
status = planning_error(
"ceded_session_requires_selection", TRAINLOG_STATUS_CONFLICT, output, output_size);
goto done;
}
// WHY: a second explicit decision starts from the durable current
// schedule. A ceded identity has no current date, so its original slot
// is available only when the user selects that identity again.
const char *slot = rows[index].current == NULL ? rows[index].original : rows[index].current;
if (strcmp(slot, yyjson_get_str(yyjson_obj_get(request, "start_date"))) >= 0) {
slots[slot_count++] = slot;
}
}
// Selected Program slots define permitted days; no rest day is synthesized.
// Position order may differ from dates, so sort candidate dates explicitly.
for (size_t outer = 0U; outer < slot_count; ++outer) {
for (size_t inner = outer + 1U; inner < slot_count; ++inner) {
if (strcmp(slots[inner], slots[outer]) < 0) {
const char *swap = slots[outer];
slots[outer] = slots[inner];
slots[inner] = swap;
}
}
}
// A previously ceded original day may now be occupied by an active
// session. It remains one calendar slot for this operation.
size_t unique_slots = 0U;
for (size_t index = 0U; index < slot_count; ++index) {
if (unique_slots == 0U || strcmp(slots[index], slots[unique_slots - 1U]) != 0) {
slots[unique_slots++] = slots[index];
}
}
slot_count = unique_slots;
for (size_t index = first; index <= last; ++index) {
if (rows[index].cede) {
continue;
}
if (next_slot >= slot_count ||
strcmp(slots[next_slot], yyjson_get_str(yyjson_obj_get(program, "end_date"))) > 0) {
status = planning_error("insufficient_or_duplicate_slots",
TRAINLOG_STATUS_INVALID_ARGUMENT,
output,
output_size);
goto done;
}
rows[index].assigned = slots[next_slot++];
}
assigned:
// CONTRACT: the choice digest includes stable identities in their supplied
// order. The fact digest includes the complete current Program projection.
{
yyjson_mut_doc *choice_document = yyjson_mut_doc_new(NULL);
yyjson_mut_val *choice_root =
choice_document == NULL ? NULL : yyjson_mut_obj(choice_document);
if (choice_document == NULL || choice_root == NULL) {
yyjson_mut_doc_free(choice_document);
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
yyjson_mut_doc_set_root(choice_document, choice_root);
(void)yyjson_mut_obj_add_strcpy(
choice_document,
choice_root,
"start_session_id",
yyjson_get_str(yyjson_obj_get(request, "start_session_id")));
(void)yyjson_mut_obj_add_strcpy(
choice_document,
choice_root,
"through_session_id",
yyjson_get_str(yyjson_obj_get(request, "through_session_id")));
(void)yyjson_mut_obj_add_strcpy(choice_document,
choice_root,
"start_date",
yyjson_get_str(yyjson_obj_get(request, "start_date")));
yyjson_mut_val *ids = yyjson_mut_arr(choice_document);
for (size_t index = 0U; index < yyjson_arr_size(ceded); ++index) {
(void)yyjson_mut_arr_add_strcpy(
choice_document, ids, yyjson_get_str(yyjson_arr_get(ceded, index)));
}
(void)yyjson_mut_obj_add_val(choice_document, choice_root, "ceded_session_ids", ids);
choices = yyjson_mut_write(choice_document, YYJSON_WRITE_NOFLAG, &choices_size);
yyjson_mut_doc_free(choice_document);
}
if (choices == NULL ||
!planning_digest(detail, detail_size, choices, choices_size, fingerprint)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
response = yyjson_mut_doc_new(NULL);
root = response == NULL ? NULL : yyjson_mut_obj(response);
moves = response == NULL ? NULL : yyjson_mut_arr(response);
if (response == NULL || root == NULL || moves == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
yyjson_mut_doc_set_root(response, root);
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
(void)yyjson_mut_obj_add_strcpy(
response, root, "revision_id", yyjson_get_str(yyjson_obj_get(program, "revision_id")));
(void)yyjson_mut_obj_add_strcpy(response, root, "preview_sha256", fingerprint);
(void)yyjson_mut_obj_add_val(response, root, "moves", moves);
for (size_t index = first; index <= last; ++index) {
yyjson_mut_val *move = yyjson_mut_obj(response);
(void)yyjson_mut_obj_add_strcpy(response, move, "program_session_id", rows[index].id);
(void)add_nullable(response, move, "original_for", rows[index].original);
(void)add_nullable(response, move, "old_for", rows[index].current);
(void)add_nullable(response, move, "new_for", rows[index].assigned);
const char *change =
rows[index].cede ? "ceded"
: rows[index].current != NULL && strcmp(rows[index].current, rows[index].assigned) == 0
? "unchanged"
: "rescheduled";
(void)yyjson_mut_obj_add_strcpy(response, move, "change", change);
(void)yyjson_mut_arr_add_val(moves, move);
}
*output = yyjson_mut_write(response, YYJSON_WRITE_NOFLAG, output_size);
status = *output == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
done:
free(choices);
yyjson_mut_doc_free(response);
yyjson_doc_free(source);
free(detail);
return status;
}
static TrainlogStatus apply_preview(TrainlogDatabase *database,
const char *operation_id,
const char *program_id,
const char *expected_revision,
const char *request_digest,
const char *preview,
char **output,
size_t *output_size) {
yyjson_doc *document = yyjson_read(preview, strlen(preview), YYJSON_READ_NOFLAG);
yyjson_val *root = document == NULL ? NULL : yyjson_doc_get_root(document);
yyjson_val *moves = root == NULL ? NULL : yyjson_obj_get(root, "moves");
sqlite3_stmt *statement = NULL;
char revision[80];
char now[32];
time_t clock_now = time(NULL);
struct tm utc;
TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR;
if (moves == NULL || clock_now == (time_t)-1 || gmtime_r(&clock_now, &utc) == NULL ||
strftime(now, sizeof(now), "%Y-%m-%dT%H:%M:%SZ", &utc) == 0U ||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
if (sqlite3_prepare_v2(database->connection,
"UPDATE programs SET revision_id=?,updated_at=? WHERE program_id=? "
"AND revision_id=? AND deleted_at IS NULL AND state='active'",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
goto done;
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO program_session_planning "
"(program_session_id,current_for,state,operation_id,updated_at) "
"VALUES(?,?,?,?,?) ON CONFLICT(program_session_id) DO UPDATE SET "
"current_for=excluded.current_for,state=excluded.state,"
"operation_id=excluded.operation_id,updated_at=excluded.updated_at",
-1,
&statement,
NULL) != SQLITE_OK) {
goto done;
}
for (size_t index = 0U; index < yyjson_arr_size(moves); ++index) {
yyjson_val *move = yyjson_arr_get(moves, index);
yyjson_val *new_for = yyjson_obj_get(move, "new_for");
if (yyjson_equals_str(yyjson_obj_get(move, "change"), "unchanged")) {
continue;
}
sqlite3_reset(statement);
sqlite3_clear_bindings(statement);
if (sqlite3_bind_text(statement,
1,
yyjson_get_str(yyjson_obj_get(move, "program_session_id")),
-1,
SQLITE_TRANSIENT) != SQLITE_OK ||
(yyjson_is_null(new_for)
? sqlite3_bind_null(statement, 2)
: sqlite3_bind_text(
statement, 2, yyjson_get_str(new_for), -1, SQLITE_TRANSIENT)) != SQLITE_OK ||
sqlite3_bind_text(statement,
3,
yyjson_equals_str(yyjson_obj_get(move, "change"), "ceded") ? "ceded"
: "active",
-1,
SQLITE_STATIC) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
goto done;
}
}
(void)sqlite3_finalize(statement);
statement = NULL;
{
size_t length = strlen(preview) + strlen(revision) + 52U;
*output = malloc(length);
if (*output == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto done;
}
*output_size = (size_t)snprintf(*output,
length,
"%.*s,\"applied\":true,\"new_revision_id\":\"%s\"}",
(int)strlen(preview) - 1,
preview,
revision);
}
if (sqlite3_prepare_v2(database->connection,
"INSERT INTO program_reschedule_operations VALUES(?,?,?,?,?,?)",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, program_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 3, request_digest, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 5, *output, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 6, now, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(statement) != SQLITE_DONE) {
goto done;
}
status = TRAINLOG_STATUS_OK;
done:
(void)sqlite3_finalize(statement);
yyjson_doc_free(document);
return status;
}
TrainlogStatus trainlog_web_programs_reschedule_json(TrainlogDatabase *database,
const char *program_id,
const char *operation_id,
const char *body,
size_t body_size,
bool commit,
char **output_json,
size_t *output_size) {
yyjson_doc *request_doc = NULL;
yyjson_val *request;
sqlite3_stmt *statement = NULL;
char request_digest[65];
char *preview = NULL;
size_t preview_size = 0U;
TrainlogStatus status;
if (database == NULL || program_id == NULL || body == NULL || output_json == NULL ||
output_size == NULL || (commit && (operation_id == NULL || operation_id[0] == '\0')) ||
body_size > 16384U || (operation_id != NULL && strlen(operation_id) > 128U)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
request_doc = yyjson_read(body, body_size, YYJSON_READ_NOFLAG);
request = request_doc == NULL ? NULL : yyjson_doc_get_root(request_doc);
if (!request_valid(request, commit) ||
!planning_digest(body, body_size, "", 0U, request_digest)) {
yyjson_doc_free(request_doc);
return planning_error("invalid_reschedule_request",
TRAINLOG_STATUS_INVALID_ARGUMENT,
output_json,
output_size);
}
if (sqlite3_exec(
database->connection, commit ? "BEGIN IMMEDIATE" : "BEGIN", NULL, NULL, NULL) !=
SQLITE_OK) {
yyjson_doc_free(request_doc);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (commit) {
if (sqlite3_prepare_v2(database->connection,
"SELECT program_id,request_sha256,response_json FROM "
"program_reschedule_operations WHERE operation_id=?",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
if (sqlite3_step(statement) == SQLITE_ROW) {
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
const char *known_digest = (const char *)sqlite3_column_text(statement, 1);
const char *saved = (const char *)sqlite3_column_text(statement, 2);
if (known_program != NULL && known_digest != NULL && saved != NULL &&
strcmp(known_program, program_id) == 0 &&
strcmp(known_digest, request_digest) == 0) {
*output_json = strdup(saved);
*output_size = *output_json == NULL ? 0U : strlen(*output_json);
status = *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK;
} else {
status = planning_error("operation_identity_conflict",
TRAINLOG_STATUS_CONFLICT,
output_json,
output_size);
}
goto finish;
}
(void)sqlite3_finalize(statement);
statement = NULL;
if (sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=? "
"UNION ALL SELECT 1 FROM program_deletions WHERE request_id=?",
-1,
&statement,
NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, operation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
int cross_command = sqlite3_step(statement);
(void)sqlite3_finalize(statement);
statement = NULL;
if (cross_command != SQLITE_DONE) {
status = cross_command == SQLITE_ROW ? planning_error("operation_identity_conflict",
TRAINLOG_STATUS_CONFLICT,
output_json,
output_size)
: TRAINLOG_STATUS_DATABASE_ERROR;
goto finish;
}
// WHY: the desktop's last imported draft snapshot cannot prove that
// Android stayed idle while offline. A recent acknowledged V2 Program
// generation is required; Android independently rejects a changed
// schedule if a Program draft starts after that acknowledgement.
status = peer_planning_fresh(database);
if (status != TRAINLOG_STATUS_OK) {
if (status == TRAINLOG_STATUS_CONFLICT) {
status = planning_error("program_peer_not_fresh", status, output_json, output_size);
}
goto finish;
}
}
status = build_preview(database, program_id, request, &preview, &preview_size);
if (status != TRAINLOG_STATUS_OK) {
if (preview != NULL) {
*output_json = preview;
*output_size = preview_size;
preview = NULL;
}
goto finish;
}
if (!commit) {
*output_json = preview;
*output_size = preview_size;
preview = NULL;
goto finish;
}
{
yyjson_doc *preview_doc = yyjson_read(preview, preview_size, YYJSON_READ_NOFLAG);
yyjson_val *preview_root = preview_doc == NULL ? NULL : yyjson_doc_get_root(preview_doc);
const char *fingerprint = yyjson_get_str(yyjson_obj_get(preview_root, "preview_sha256"));
if (fingerprint == NULL ||
strcmp(fingerprint, yyjson_get_str(yyjson_obj_get(request, "preview_sha256"))) != 0) {
status = planning_error(
"stale_reschedule_preview", TRAINLOG_STATUS_CONFLICT, output_json, output_size);
} else {
status = apply_preview(database,
operation_id,
program_id,
yyjson_get_str(yyjson_obj_get(request, "expected_revision")),
request_digest,
preview,
output_json,
output_size);
}
yyjson_doc_free(preview_doc);
}
finish:
(void)sqlite3_finalize(statement);
free(preview);
yyjson_doc_free(request_doc);
if (status == TRAINLOG_STATUS_OK && commit) {
if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
return status;
}
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
if (status != TRAINLOG_STATUS_OK && *output_json == NULL) {
return planning_error("reschedule_unavailable", status, output_json, output_size);
}
return status;
}

View file

@ -365,10 +365,12 @@ static TrainlogStatus add_program_session_json(TrainlogDatabase *database,
!add_column_text(document, session, "title", statement, 2) ||
!add_column_text(document, session, "session_type", statement, 3) ||
!add_column_text(document, session, "planned_for", statement, 4) ||
!add_column_text(document, session, "note", statement, 5) ||
!add_column_text(document, session, "execution_state", statement, 6) ||
!add_column_text(document, session, "execution_session_id", statement, 7) ||
!add_column_text(document, session, "execution_started_at", statement, 8) ||
!add_column_text(document, session, "current_for", statement, 5) ||
!add_column_text(document, session, "planning_state", statement, 6) ||
!add_column_text(document, session, "note", statement, 7) ||
!add_column_text(document, session, "execution_state", statement, 8) ||
!add_column_text(document, session, "execution_session_id", statement, 9) ||
!add_column_text(document, session, "execution_started_at", statement, 10) ||
!yyjson_mut_obj_add_val(document, session, "occurrences", entries) ||
!yyjson_mut_arr_add_val(sessions, session)) {
return TRAINLOG_STATUS_DATABASE_ERROR;
@ -383,7 +385,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
yyjson_mut_val *sessions) {
static const char SESSION_SQL[] =
"SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for,"
"ps.note,CASE WHEN pe.state='completed' THEN 'completed' "
"CASE WHEN pp.state='ceded' THEN NULL ELSE COALESCE(pp.current_for,ps.planned_for) END,"
"COALESCE(pp.state,'active'),ps.note,"
"CASE WHEN pe.state='completed' THEN 'completed' "
"WHEN pe.state='in_progress' THEN 'in_progress' "
"WHEN pe.state='deleted' THEN 'deleted' "
"WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE "
@ -394,7 +398,9 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database,
* CONTRACT: expose that timestamp only for completed executions.
* INVARIANT: the imported planned_for date and history are read only. */
"CASE WHEN pe.state='completed' THEN actual.started_at ELSE NULL END "
"FROM program_sessions ps LEFT JOIN program_session_executions pe "
"FROM program_sessions ps LEFT JOIN program_session_planning pp "
"ON pp.program_session_id=ps.program_session_id "
"LEFT JOIN program_session_executions pe "
"ON pe.program_session_id=ps.program_session_id "
"LEFT JOIN sessions actual ON actual.session_id=pe.session_id "
"WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id";
@ -1165,7 +1171,25 @@ static TrainlogStatus program_replay_archive(TrainlogDatabase *database,
result = SQLITE_OK;
}
(void)sqlite3_finalize(statement);
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
if (result != SQLITE_OK || *found) {
return result == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
}
statement = NULL;
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_reschedule_operations WHERE operation_id=?",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
(void)sqlite3_finalize(statement);
return result == SQLITE_ROW ? TRAINLOG_STATUS_CONFLICT
: result == SQLITE_DONE ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR;
}
static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
@ -1357,7 +1381,9 @@ static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
}
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=?1",
"SELECT 1 FROM program_requests WHERE request_id=?1 "
"UNION ALL SELECT 1 FROM program_reschedule_operations "
"WHERE operation_id=?1",
-1,
&statement,
NULL);
@ -1609,7 +1635,7 @@ static char *program_build_preparation_body(yyjson_val *session,
command,
root,
"planned_for",
yyjson_val_mut_copy(command, yyjson_obj_get(session, "planned_for")));
yyjson_val_mut_copy(command, yyjson_obj_get(session, "current_for")));
(void)yyjson_mut_obj_add_val(
command, root, "notes", yyjson_val_mut_copy(command, yyjson_obj_get(session, "note")));
(void)yyjson_mut_obj_add_strcpy(command, root, "editing_state", "draft");
@ -1652,6 +1678,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
yyjson_doc_free(document);
return TRAINLOG_STATUS_NOT_FOUND;
}
if (!yyjson_equals_str(yyjson_obj_get(session, "planning_state"), "active")) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
body = program_build_preparation_body(session, program_id, program_session_id, &body_size);
yyjson_doc_free(document);
if (body == NULL) {

View file

@ -705,14 +705,22 @@ static enum MHD_Result queue_owned_programs_json(struct MHD_Connection *connecti
return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
}
if (status == TRAINLOG_STATUS_CONFLICT) {
enum MHD_Result result =
queue_json(connection,
MHD_HTTP_CONFLICT,
json == NULL ? "{\"error\":\"program_conflict\"}\n" : json,
NULL);
free(json);
return queue_json(
connection, MHD_HTTP_CONFLICT, "{\"error\":\"program_conflict\"}\n", NULL);
return result;
}
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
enum MHD_Result result =
queue_json(connection,
MHD_HTTP_UNPROCESSABLE_CONTENT,
json == NULL ? "{\"error\":\"invalid_program\"}\n" : json,
NULL);
free(json);
return queue_json(
connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_program\"}\n", NULL);
return result;
}
return queue_owned_sessions_json(connection, status, json, json_size);
}
@ -930,6 +938,8 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive";
static const char DELETE_SUFFIX[] = "/delete";
static const char PREVIEW_SUFFIX[] = "/reschedule/preview";
static const char APPLY_SUFFIX[] = "/reschedule/apply";
const char *identity;
const char *sessions_marker;
char *json = NULL;
@ -950,6 +960,38 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
identity = url + strlen(PREFIX);
identity_length = strlen(identity);
sessions_marker = strstr(identity, "/sessions/");
if ((identity_length > strlen(PREVIEW_SUFFIX) &&
strcmp(identity + identity_length - strlen(PREVIEW_SUFFIX), PREVIEW_SUFFIX) == 0) ||
(identity_length > strlen(APPLY_SUFFIX) &&
strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0)) {
bool commit = strcmp(identity + identity_length - strlen(APPLY_SUFFIX), APPLY_SUFFIX) == 0;
size_t suffix_length = commit ? strlen(APPLY_SUFFIX) : strlen(PREVIEW_SUFFIX);
char program_id[TRAINLOG_ID_MAX + 1U];
const char *operation_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection) ||
!copy_path_component(
program_id, sizeof(program_id), identity, identity_length - suffix_length) ||
(commit && (operation_id == NULL || operation_id[0] == '\0'))) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
status = trainlog_web_programs_reschedule_json(context->database,
program_id,
operation_id,
request->body,
request->body_size,
commit,
&json,
&json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
if (identity_length > strlen(ARCHIVE_SUFFIX) &&
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method);
@ -1893,6 +1935,14 @@ static enum MHD_Result handle_request(void *closure,
context->database, search, offset, limit, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
if (strcmp(url, "/api/v1/sessions/sport") == 0) {
const char *start =
MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "start");
const char *end = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "end");
TrainlogStatus status = trainlog_web_sessions_sport_json(
context->database, start, end, offset, limit, &json, &json_size);
return queue_owned_sessions_json(connection, status, json, json_size);
}
for (index = 0U; index < sizeof(collections) / sizeof(collections[0]); ++index) {
if (strcmp(url, collections[index].path) == 0) {
TrainlogWebSessionsPageQuery query = {

View file

@ -10,6 +10,7 @@
#include <yyjson.h>
#include "database_internal.h"
#include "timestamp.h"
#include "trainlog/id.h"
typedef struct ListDefinition {
@ -95,6 +96,117 @@ write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database,
const char *start,
const char *end,
size_t offset,
size_t limit,
char **output_json,
size_t *output_size) {
static const char SQL[] =
"SELECT s.session_id,s.session_type,s.started_at,s.ended_at,"
"COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM "
"session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE "
"se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) "
"FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND "
"unixepoch(s.started_at)<unixepoch(?2) AND "
"(CASE WHEN unixepoch(s.ended_at)>unixepoch(s.started_at) "
"THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) "
"END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE "
"c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) "
"ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4";
sqlite3_stmt *statement = NULL;
yyjson_mut_doc *document = NULL;
yyjson_mut_val *root;
yyjson_mut_val *items;
TrainlogStatus status;
TrainlogStatus end_status;
size_t count = 0U;
int step = SQLITE_DONE;
TrainlogTimestampKey start_key;
TrainlogTimestampKey end_key;
if (database == NULL || start == NULL || end == NULL || output_json == NULL ||
output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX ||
offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U ||
!trainlog_timestamp_parse(start, strlen(start), &start_key) ||
!trainlog_timestamp_parse(end, strlen(end), &end_key) ||
trainlog_timestamp_compare(&start_key, &end_key) >= 0) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
status = trainlog_database_read_snapshot_begin(database);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK ||
sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK ||
sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish_sport;
}
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
items = document == NULL ? NULL : yyjson_mut_arr(document);
if (root == NULL || items == NULL) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish_sport;
}
yyjson_mut_doc_set_root(document, root);
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_uint(document, root, "offset", offset) ||
!yyjson_mut_obj_add_val(document, root, "items", items)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish_sport;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *item;
if (count == limit) {
++count;
break;
}
item = yyjson_mut_obj(document);
if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) ||
!add_nullable_text(document, item, "session_type", statement, 1) ||
!add_nullable_text(document, item, "started_at", statement, 2) ||
!add_nullable_text(document, item, "ended_at", statement, 3) ||
!add_nullable_text(document, item, "label", statement, 4) ||
!yyjson_mut_arr_add_val(items, item)) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish_sport;
}
++count;
}
if (step != SQLITE_DONE && count <= limit) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
goto finish_sport;
}
if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) ||
!yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) {
status = TRAINLOG_STATUS_SYSTEM_ERROR;
goto finish_sport;
}
status = TRAINLOG_STATUS_OK;
finish_sport:
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
status == TRAINLOG_STATUS_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
if (status == TRAINLOG_STATUS_OK) {
status = end_status;
}
if (status != TRAINLOG_STATUS_OK) {
yyjson_mut_doc_free(document);
return status;
}
return write_document(document, output_json, output_size);
}
TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database,
const TrainlogWebSessionsPageQuery *query,
char **output_json,

View file

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

View file

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

View file

@ -225,7 +225,8 @@ static bool test_factual_models(void) {
{
yyjson_val *program = yyjson_obj_get(root, "active_program");
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") ==
0);
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")),
"2026-09-21") == 0);
}
@ -250,6 +251,123 @@ static bool test_factual_models(void) {
return true;
}
int main(void) {
return test_empty_and_periods() && test_factual_models() ? 0 : 1;
static bool check_next_session(TrainlogDatabase *database,
const char *expected_id,
const char *expected_title,
const char *expected_date) {
yyjson_doc *document = NULL;
yyjson_val *program;
yyjson_val *id;
yyjson_val *title;
yyjson_val *date;
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program");
CHECK(!yyjson_is_null(program));
id = yyjson_obj_get(program, "next_session_id");
title = yyjson_obj_get(program, "next_session_title");
date = yyjson_obj_get(program, "next_session_planned_for");
CHECK(expected_id == NULL ? yyjson_is_null(id)
: yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0);
CHECK(expected_title == NULL
? yyjson_is_null(title)
: yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0);
CHECK(expected_date == NULL
? yyjson_is_null(date)
: yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0);
yyjson_doc_free(document);
return true;
}
static bool test_current_program_plan(void) {
TrainlogDatabase *database = NULL;
yyjson_doc *document = NULL;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
/* WHY: source dates remain historical; current planning and execution are
* independent facts. These rows exercise their precedence without touching
* any user database or invoking the rescheduling command. */
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL,"
"NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
"NULL);"
"INSERT INTO program_sessions VALUES"
"('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL),"
"('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL),"
"('ps_later','pg_current',2,'Later','training','2026-10-03',NULL),"
"('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL),"
"('ps_undated','pg_current',4,'Undated','training',NULL,NULL);",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_planning VALUES"
"('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z'),"
"('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_s2c','pg_current','se_fixture_1','in_progress',"
"'2026-10-02T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"UPDATE program_session_executions SET state='completed' "
"WHERE program_session_id='ps_s2c';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02"));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z'),"
"('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, "ps_undated", "Undated", NULL));
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES"
"('ps_undated','pg_current','se_fixture_4','completed',"
"'2026-10-04T12:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(check_next_session(database, NULL, NULL, NULL));
CHECK(sqlite3_exec(database->connection,
"UPDATE programs SET state='archived' WHERE program_id='pg_current';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
yyjson_doc_free(document);
document = NULL;
CHECK(sqlite3_exec(database->connection,
"UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' "
"WHERE program_id='pg_current';",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document));
CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program")));
yyjson_doc_free(document);
trainlog_database_close(database);
return true;
}
int main(void) {
return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1;
}

View file

@ -608,10 +608,447 @@ static bool legacy_integer_weight_zero_prepares_editable_draft(void) {
return true;
}
static bool ordered_reschedule_is_atomic_and_idempotent(void) {
static const char *const DATES[] = {
"2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26",
"2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02", "2026-10-03",
"2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", "2026-10-10",
"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17",
};
static const char REQUEST[] = "{\"start_session_id\":\"pgs_fixture_07\","
"\"through_session_id\":\"pgs_fixture_22\","
"\"start_date\":\"2026-10-01\","
"\"ceded_session_ids\":[\"pgs_fixture_11\",\"pgs_fixture_17\"],"
"\"expected_revision\":\"pgr_fixture\"}";
TrainlogDatabase *database = NULL;
char *preview = NULL;
char *applied = NULL;
char *replayed = NULL;
char *request = NULL;
size_t size = 0U;
size_t applied_size = 0U;
char fingerprint[80];
char sql[1024];
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
"created_at,updated_at,revision_id,source_format,source_version,"
"source_payload_sha256) VALUES('pg_fixture','Fixture','active',"
"'2026-09-21','2026-10-18','2026-09-20T00:00:00Z',"
"'2026-09-20T00:00:00Z','pgr_fixture','trainlog-program',1,"
"'0000000000000000000000000000000000000000000000000000000000000000')",
NULL,
NULL,
NULL) == SQLITE_OK);
for (size_t index = 0U; index < 24U; ++index) {
(void)snprintf(sql,
sizeof(sql),
"INSERT INTO program_sessions VALUES('pgs_fixture_%02zu','pg_fixture',"
"%zu,'Session %zu','training','%s',NULL)",
index,
index,
index,
DATES[index]);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
if (index < 7U) {
const char *actual = index == 5U ? "2026-09-28T09:00:00+02:00"
: index == 6U ? "2026-09-30T17:00:00+02:00"
: "2026-09-21T09:00:00+02:00";
(void)snprintf(sql,
sizeof(sql),
"INSERT INTO sessions(session_id,started_at) "
"VALUES('se_fixture_%02zu','%s')",
index,
actual);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
(void)snprintf(sql,
sizeof(sql),
"INSERT INTO program_session_executions VALUES("
"'pgs_fixture_%02zu','pg_fixture','se_fixture_%02zu',"
"'completed','2026-09-30T00:00:00Z')",
index,
index);
CHECK(sqlite3_exec(database->connection, sql, NULL, NULL, NULL) == SQLITE_OK);
}
}
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sessions(session_id,started_at) "
"VALUES('se_evening_stretch','2026-10-01T20:00:00+02:00')",
NULL,
NULL,
NULL) == SQLITE_OK);
char *invalid_date = replace_once(REQUEST, "2026-10-01", "2026-02-30");
CHECK(invalid_date != NULL);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
NULL,
invalid_date,
strlen(invalid_date),
false,
&replayed,
&size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
free(invalid_date);
free(replayed);
replayed = NULL;
char *too_late = replace_once(REQUEST, "2026-10-01", "2026-10-16");
CHECK(too_late != NULL);
CHECK(trainlog_web_programs_reschedule_json(
database, "pg_fixture", NULL, too_late, strlen(too_late), false, &replayed, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
CHECK(strstr(replayed, "insufficient_or_duplicate_slots") != NULL);
free(too_late);
free(replayed);
replayed = NULL;
// The DST transition on October 25 is a rest day in this fixture.
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
"created_at,updated_at,revision_id,source_format,source_version,"
"source_payload_sha256) VALUES('pg_dst','DST','active','2026-10-24',"
"'2026-10-27','2026-10-20T00:00:00Z','2026-10-20T00:00:00Z',"
"'pgr_dst','trainlog-program',1,"
"'0000000000000000000000000000000000000000000000000000000000000000');"
"INSERT INTO program_sessions VALUES"
"('pgs_dst_0','pg_dst',0,'Before','training','2026-10-24',NULL),"
"('pgs_dst_1','pg_dst',1,'After','training','2026-10-26',NULL),"
"('pgs_dst_2','pg_dst',2,'Next','training','2026-10-27',NULL)",
NULL,
NULL,
NULL) == SQLITE_OK);
static const char DST_REQUEST[] =
"{\"start_session_id\":\"pgs_dst_0\",\"through_session_id\":\"pgs_dst_2\","
"\"start_date\":\"2026-10-24\",\"ceded_session_ids\":[],"
"\"expected_revision\":\"pgr_dst\"}";
CHECK(
trainlog_web_programs_reschedule_json(
database, "pg_dst", NULL, DST_REQUEST, strlen(DST_REQUEST), false, &replayed, &size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(replayed, "\"new_for\":\"2026-10-26\"") != NULL);
CHECK(strstr(replayed, "2026-10-25") == NULL);
free(replayed);
replayed = NULL;
CHECK(trainlog_web_programs_reschedule_json(
database, "pg_fixture", NULL, REQUEST, strlen(REQUEST), false, &preview, &size) ==
TRAINLOG_STATUS_OK);
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
CHECK(strstr(preview,
"\"program_session_id\":\"pgs_fixture_07\","
"\"original_for\":\"2026-09-29\","
"\"old_for\":\"2026-09-29\","
"\"new_for\":\"2026-10-01\"") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
request = malloc(strlen(REQUEST) + strlen(fingerprint) + 32U);
CHECK(request != NULL);
(void)snprintf(request,
strlen(REQUEST) + strlen(fingerprint) + 32U,
"%.*s,\"preview_sha256\":\"%s\"}",
(int)strlen(REQUEST) - 1,
REQUEST,
fingerprint);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sync_generations(generation_id,run_id,producer_peer_id,"
"consumer_peer_id,producer_kind,generated_at,manifest_sha256,status,"
"manifest_json,staging_path) VALUES('gen_fixture','sy_fixture',"
"'peer_desktop','peer_phone','desktop',"
"strftime('%Y-%m-%dT%H:%M:%SZ','now'),'digest','captured','{}','/tmp')",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sync_generation_artifacts VALUES('gen_fixture','programs-v2',"
"'trainlog-programs',2,'programs-v2.json',0,'digest',0)",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
request,
strlen(request),
true,
&replayed,
&size) == TRAINLOG_STATUS_CONFLICT);
CHECK(strstr(replayed, "program_peer_not_fresh") != NULL);
free(replayed);
replayed = NULL;
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 0);
CHECK(sqlite3_exec(database->connection,
"UPDATE sync_generations SET status='acknowledged',"
"acknowledged_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') "
"WHERE generation_id='gen_fixture'",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
request,
strlen(request),
true,
&applied,
&applied_size) == TRAINLOG_STATUS_OK);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning") == 11);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_planning WHERE state='ceded'") ==
2);
CHECK(scalar(database,
"SELECT COUNT(DISTINCT current_for) FROM program_session_planning "
"WHERE state='active'") == 9);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_executions "
"WHERE state='completed'") == 7);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sessions WHERE "
"session_id='se_fixture_05' AND started_at='2026-09-28T09:00:00+02:00'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sessions WHERE "
"session_id='se_fixture_06' AND started_at='2026-09-30T17:00:00+02:00'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sessions WHERE "
"session_id='se_evening_stretch' AND started_at='2026-10-01T20:00:00+02:00'") ==
1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_sessions WHERE "
"planned_for='2026-09-29' AND program_session_id='pgs_fixture_07'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"program_session_id='pgs_fixture_09' AND current_for='2026-10-03'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"program_session_id='pgs_fixture_22'") == 0);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
request,
strlen(request),
true,
&replayed,
&size) == TRAINLOG_STATUS_OK);
CHECK(strcmp(applied, replayed) == 0);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
free(replayed);
replayed = NULL;
char *different_request = replace_once(request, "2026-10-01", "2026-10-02");
CHECK(different_request != NULL);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_fixture",
different_request,
strlen(different_request),
true,
&replayed,
&size) == TRAINLOG_STATUS_CONFLICT);
free(different_request);
free(replayed);
replayed = NULL;
char new_revision[80];
char next_fingerprint[80];
CHECK(extract_string(applied, "new_revision_id", new_revision, sizeof(new_revision)));
CHECK(scalar(database,
"SELECT revision_sequence FROM program_revision_sequences "
"WHERE program_id='pg_fixture'") == 1);
char second_choice[512];
(void)snprintf(second_choice,
sizeof(second_choice),
"{\"start_session_id\":\"pgs_fixture_07\","
"\"through_session_id\":\"pgs_fixture_09\","
"\"start_date\":\"2026-10-01\",\"ceded_session_ids\":[],"
"\"expected_revision\":\"%s\"}",
new_revision);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
NULL,
second_choice,
strlen(second_choice),
false,
&replayed,
&size) == TRAINLOG_STATUS_OK);
CHECK(strstr(replayed,
"\"program_session_id\":\"pgs_fixture_09\","
"\"original_for\":\"2026-10-01\","
"\"old_for\":\"2026-10-03\","
"\"new_for\":\"2026-10-03\"") != NULL);
free(replayed);
replayed = NULL;
char *next_choice = replace_once(REQUEST, "pgr_fixture", new_revision);
CHECK(next_choice != NULL);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
NULL,
next_choice,
strlen(next_choice),
false,
&replayed,
&size) == TRAINLOG_STATUS_OK);
CHECK(extract_string(replayed, "preview_sha256", next_fingerprint, sizeof(next_fingerprint)));
free(replayed);
replayed = NULL;
char *next_request = malloc(strlen(next_choice) + strlen(next_fingerprint) + 32U);
CHECK(next_request != NULL);
(void)snprintf(next_request,
strlen(next_choice) + strlen(next_fingerprint) + 32U,
"%.*s,\"preview_sha256\":\"%s\"}",
(int)strlen(next_choice) - 1,
next_choice,
next_fingerprint);
CHECK(sqlite3_exec(database->connection,
"CREATE TRIGGER fail_reschedule_fixture BEFORE INSERT ON "
"program_reschedule_operations WHEN NEW.operation_id='op_failure' "
"BEGIN SELECT RAISE(ABORT,'fixture failure'); END",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_failure",
next_request,
strlen(next_request),
true,
&replayed,
&size) == TRAINLOG_STATUS_DATABASE_ERROR);
free(replayed);
replayed = NULL;
CHECK(scalar(database, "SELECT COUNT(*) FROM program_reschedule_operations") == 1);
(void)snprintf(sql,
sizeof(sql),
"SELECT COUNT(*) FROM programs WHERE program_id='pg_fixture' "
"AND revision_id='%s'",
new_revision);
CHECK(scalar(database, sql) == 1);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO program_session_executions VALUES("
"'pgs_fixture_07','pg_fixture','se_late','completed',"
"'2026-10-01T10:00:00Z')",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_fixture",
"op_stale",
next_request,
strlen(next_request),
true,
&replayed,
&size) == TRAINLOG_STATUS_CONFLICT);
CHECK(strstr(replayed, "prepared_or_executed_session") != NULL);
free(replayed);
free(next_request);
free(next_choice);
free(applied);
free(preview);
free(request);
trainlog_database_close(database);
return true;
}
static bool calendar_move_preserves_other_sessions_and_restores_ceded(void) {
/* CONTRACT: an occupied target and a legacy ceded optional identity are
* independent Program sessions. The repeated command proves durable
* idempotence without changing the imported definitions. */
TrainlogDatabase *database = NULL;
char *preview = NULL;
char *applied = NULL;
char *replay = NULL;
char *request = NULL;
size_t size = 0U;
char fingerprint[80];
char revision[80] = "pgr_calendar";
static const char *const TARGETS[] = {"2026-10-04", "2026-10-03", "2026-10-10"};
static const char *const IDENTITIES[] = {"pgs_main", "pgs_main", "pgs_optional"};
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO programs(program_id,title,state,start_date,end_date,"
"created_at,updated_at,revision_id,source_format,source_version,"
"source_payload_sha256) VALUES('pg_calendar','Fixture','active',"
"'2026-10-01','2026-10-18','2026-10-01T00:00:00Z',"
"'2026-10-01T00:00:00Z','pgr_calendar','trainlog-program',1,"
"'0000000000000000000000000000000000000000000000000000000000000000');"
"INSERT INTO program_sessions VALUES"
"('pgs_main','pg_calendar',0,'S2 D','training','2026-10-01',NULL),"
"('pgs_other','pg_calendar',1,'S2 E','training','2026-10-03',NULL),"
"('pgs_optional','pg_calendar',2,'S2 F FACULTATIVE','training',"
"'2026-10-10',NULL);"
"INSERT INTO program_session_planning(program_session_id,current_for,"
"state,operation_id,updated_at) VALUES"
"('pgs_optional',NULL,'ceded','pr_old','2026-10-01T00:00:00Z')",
NULL,
NULL,
NULL) == SQLITE_OK);
for (size_t index = 0U; index < 3U; ++index) {
char choice[512];
char operation_id[48];
(void)snprintf(operation_id, sizeof(operation_id), "pr_calendar_move_%zu", index);
(void)snprintf(choice,
sizeof(choice),
"{\"start_session_id\":\"%s\",\"through_session_id\":\"%s\","
"\"start_date\":\"%s\",\"ceded_session_ids\":[],"
"\"expected_revision\":\"%s\"}",
IDENTITIES[index],
IDENTITIES[index],
TARGETS[index],
revision);
CHECK(trainlog_web_programs_reschedule_json(
database, "pg_calendar", NULL, choice, strlen(choice), false, &preview, &size) ==
TRAINLOG_STATUS_OK);
CHECK(extract_string(preview, "preview_sha256", fingerprint, sizeof(fingerprint)));
CHECK(strstr(preview, "\"change\":\"ceded\"") == NULL);
request = malloc(strlen(choice) + strlen(fingerprint) + 32U);
CHECK(request != NULL);
(void)snprintf(request,
strlen(choice) + strlen(fingerprint) + 32U,
"%.*s,\"preview_sha256\":\"%s\"}",
(int)strlen(choice) - 1,
choice,
fingerprint);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_calendar",
operation_id,
request,
strlen(request),
true,
&applied,
&size) == TRAINLOG_STATUS_OK);
CHECK(trainlog_web_programs_reschedule_json(database,
"pg_calendar",
operation_id,
request,
strlen(request),
true,
&replay,
&size) == TRAINLOG_STATUS_OK);
CHECK(strcmp(applied, replay) == 0);
CHECK(extract_string(applied, "new_revision_id", revision, sizeof(revision)));
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_sessions WHERE "
"program_id='pg_calendar'") == 3);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"state='ceded'") == (index < 2U ? 1 : 0));
free(request);
free(preview);
free(applied);
free(replay);
request = NULL;
preview = NULL;
applied = NULL;
replay = NULL;
}
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_session_planning WHERE "
"current_for='2026-10-03'") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_sessions WHERE "
"planned_for='2026-10-03'") == 1);
trainlog_database_close(database);
return true;
}
int main(void) {
return import_archive_and_prepare() && strict_import_rejections() &&
mixed_profile_prepare_regression() &&
legacy_integer_weight_zero_prepares_editable_draft()
legacy_integer_weight_zero_prepares_editable_draft() &&
ordered_reschedule_is_atomic_and_idempotent() &&
calendar_move_preserves_other_sessions_and_restores_ceded()
? EXIT_SUCCESS
: EXIT_FAILURE;
}

View file

@ -438,9 +438,59 @@ static bool withdrawal_is_durable_and_preserves_evidence(void) {
return true;
}
static bool sport_history_intersection_and_pagination(void) {
TrainlogDatabase *database = NULL;
char *page = NULL;
size_t size = 0U;
const char *start = "2026-10-01T00:00:00Z";
const char *end = "2026-10-02T00:00:00Z";
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO sessions(session_id,session_type,started_at,ended_at) VALUES"
"('se_before','training','2026-09-30T23:00:00Z','2026-10-01T01:00:00Z'),"
"('se_inside','training','2026-10-01T04:00:00Z','2026-10-01T05:00:00Z'),"
"('se_point','max_test','2026-10-01T08:00:00Z',NULL),"
"('se_after','training','2026-10-02T00:00:00Z','2026-10-02T01:00:00Z');",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_sessions_sport_json(database, start, end, 0U, 1U, &page, &size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, "se_before") != NULL);
CHECK(strstr(page, "se_inside") == NULL);
CHECK(strstr(page, "\"more\":true") != NULL);
free(page);
page = NULL;
CHECK(trainlog_web_sessions_sport_json(database, start, end, 1U, 2U, &page, &size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, "se_inside") != NULL);
CHECK(strstr(page, "se_point") != NULL);
CHECK(strstr(page, "se_after") == NULL);
CHECK(strstr(page, "\"more\":false") != NULL);
free(page);
page = NULL;
CHECK(sqlite3_exec(database->connection,
"DELETE FROM sessions WHERE session_id='se_inside'",
NULL,
NULL,
NULL) == SQLITE_OK);
CHECK(trainlog_web_sessions_sport_json(database, start, end, 0U, 64U, &page, &size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(page, "se_inside") == NULL);
CHECK(strstr(page, "se_before") != NULL);
free(page);
page = NULL;
CHECK(trainlog_web_sessions_sport_json(database, "invalid", end, 0U, 64U, &page, &size) ==
TRAINLOG_STATUS_INVALID_ARGUMENT);
trainlog_database_close(database);
return true;
}
int main(void) {
return creation_replay_revision_and_pagination() &&
withdrawal_is_durable_and_preserves_evidence()
withdrawal_is_durable_and_preserves_evidence() &&
sport_history_intersection_and_pagination()
? 0
: 1;
}

View file

@ -38,6 +38,8 @@ export interface ProgramSession {
title: string
session_type: 'training' | 'max_test'
planned_for: string | null
current_for: string | null
planning_state: 'active' | 'ceded'
note: string | null
execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted'
execution_session_id: string | null
@ -62,6 +64,30 @@ export interface ProgramDetail {
sessions: ProgramSession[]
}
export interface RescheduleChoice {
start_session_id: string
through_session_id: string
start_date: string
ceded_session_ids: string[]
expected_revision: string
}
export interface RescheduleMove {
program_session_id: string
original_for: string | null
old_for: string | null
new_for: string | null
change: 'rescheduled' | 'ceded' | 'unchanged'
}
export interface ReschedulePreview {
api_version: 1
program_id: string
revision_id: string
preview_sha256: string
moves: RescheduleMove[]
}
export interface ProgramImportPreview {
api_version: 1
program_id: string
@ -106,6 +132,47 @@ function requestId(prefix = 'web'): string {
return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
}
export function newRescheduleOperationId(): string {
return requestId('pr')
}
async function rescheduleRequest(
programId: string,
mode: 'preview' | 'apply',
choice: RescheduleChoice & { preview_sha256?: string },
operationId?: string,
): Promise<ReschedulePreview> {
const csrf = await mutationCsrfToken()
const response = await fetch(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/reschedule/${mode}`,
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': operationId ?? requestId('pr'),
},
body: JSON.stringify(choice),
},
)
if (!response.ok) throw new Error(await errorReason(response))
const value: unknown = await response.json()
if (!object(value) || value.api_version !== 1 || value.program_id !== programId ||
typeof value.revision_id !== 'string' || typeof value.preview_sha256 !== 'string' ||
!Array.isArray(value.moves)) throw new TypeError('aperçu de recalage invalide')
return value as unknown as ReschedulePreview
}
export function previewProgramReschedule(programId: string, choice: RescheduleChoice) {
return rescheduleRequest(programId, 'preview', choice)
}
export function applyProgramReschedule(
programId: string, choice: RescheduleChoice, previewSha256: string, operationId: string,
) {
return rescheduleRequest(programId, 'apply', { ...choice, preview_sha256: previewSha256 }, operationId)
}
export async function fetchAllPrograms(
search = '',
state = '',

View file

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { newSleepId, sleepTimestamp } from "./sleepDiary";
import { fetchSleepDiary, newSleepId, sleepTimestamp } from "./sleepDiary";
describe("sleepTimestamp", () => {
it("serializes browser timestamps at the C17 contract precision", () => {
@ -22,3 +22,37 @@ describe("newSleepId", () => {
vi.unstubAllGlobals();
});
});
describe("fetchSleepDiary", () => {
it("refuses to call a capped unbounded history complete", async () => {
const entry = {
entry_id: "sd_test",
revision_id: "sdr_test",
night_start_date: "2026-09-20",
night_end_date: "2026-09-21",
created_at: "2026-09-20T18:00:00+02:00",
updated_at: "2026-09-21T08:00:00+02:00",
sleep_quality: null,
wake_quality: null,
day_form: null,
treatment_and_notes: "",
publication_status: "draft",
events: [],
intakes: [],
};
const entries = Array.from({ length: 3660 }, (_, index) => ({
...entry,
entry_id: `sd_${index}`,
}));
const response = { api_version: 1, entries, summary: { nights: 3660 } };
const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(JSON.stringify(response)),
);
try {
await expect(fetchSleepDiary()).rejects.toThrow("sleep_diary_history_limit_reached");
expect(fetch.mock.calls[0][0]).toContain("limit=3660");
} finally {
fetch.mockRestore();
}
});
});

View file

@ -189,6 +189,11 @@ export async function fetchSleepDiary(
!object(value.summary)
)
throw new TypeError("sleep_diary_invalid");
// CONTRACT: the server caps one history read at 3660 nights and does not
// expose a continuation cursor. At the cap, completeness is unknowable;
// fail explicitly instead of labeling a possibly truncated subset "All".
if (!startDate && !endDate && value.entries.length === 3660)
throw new RangeError("sleep_diary_history_limit_reached");
return value as unknown as SleepSnapshot;
}

View file

@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchSportSessions } from "./sportSessions";
afterEach(() => vi.unstubAllGlobals());
describe("completed sport history read", () => {
const item = (index: number) => ({
identity: `se_${index}`,
label: `Session ${index}`,
session_type: "training",
started_at: `2026-10-01T0${index}:00:00+02:00`,
ended_at: null,
});
it("follows all pages and retains each factual stable identity", async () => {
const fetch = vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({
api_version: 1, offset: 0, more: true, next_offset: 64, items: [item(1)],
}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({
api_version: 1, offset: 64, more: false, next_offset: 128, items: [item(2)],
}) });
vi.stubGlobal("fetch", fetch);
const sessions = await fetchSportSessions("2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z");
expect(sessions.map((session) => session.identity)).toEqual(["se_1", "se_2"]);
expect(String(fetch.mock.calls[1][0])).toContain("offset=64");
});
it("fails closed on stale cursors and duplicate identities", async () => {
vi.stubGlobal("fetch", vi.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({
api_version: 1, offset: 0, more: true, next_offset: 64, items: [item(1)],
}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({
api_version: 1, offset: 64, more: false, next_offset: 128, items: [item(1)],
}) }));
await expect(fetchSportSessions("a", "b")).rejects.toThrow(/Duplicate/);
});
});

View file

@ -0,0 +1,57 @@
export interface SportSession {
identity: string;
session_type: string;
label: string;
started_at: string;
ended_at: string | null;
}
interface SportPage {
api_version: 1;
offset: number;
more: boolean;
next_offset: number;
items: SportSession[];
}
function validSession(value: unknown): value is SportSession {
if (typeof value !== "object" || value === null) return false;
const item = value as Record<string, unknown>;
return typeof item.identity === "string" &&
typeof item.session_type === "string" &&
typeof item.label === "string" &&
typeof item.started_at === "string" &&
(item.ended_at === null || typeof item.ended_at === "string");
}
/** CONTRACT: each read is a fresh factual history projection. Pagination must
* complete before callers may present absence or permit a complete PDF. */
export async function fetchSportSessions(
start: string,
end: string,
signal?: AbortSignal,
): Promise<SportSession[]> {
const sessions: SportSession[] = [];
const ids = new Set<string>();
let offset = 0;
for (;;) {
const query = new URLSearchParams({ start, end, offset: String(offset), limit: "64" });
const response = await fetch(`/api/v1/sessions/sport?${query}`, { signal });
if (!response.ok) throw new Error(`Sport history HTTP ${response.status}`);
const page: SportPage = await response.json();
if (page.api_version !== 1 || page.offset !== offset ||
!Array.isArray(page.items) || !page.items.every(validSession) ||
typeof page.more !== "boolean" ||
!Number.isSafeInteger(page.next_offset) || page.next_offset <= offset) {
throw new TypeError("Invalid sport history page");
}
for (const item of page.items) {
if (ids.has(item.identity)) throw new TypeError("Duplicate sport session identity");
ids.add(item.identity);
sessions.push(item);
}
if (!page.more) return sessions;
if (page.items.length === 0) throw new TypeError("Empty sport history page");
offset = page.next_offset;
}
}

View file

@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { dashboardFixture } from '../test/dashboardFixtures'
import { BODY_ZONE_SVG_MAP, BodyZoneFigure, muscleIntensityLevel, UNMAPPED_BODY_ZONE_IDS } from './BodyZoneFigure'
import { ANDROID_BODY_HEAD, ANDROID_BODY_NECK, ANDROID_BODY_REGIONS } from './androidBodyFigureGeometry'
describe('silhouette BODY ZONES canonique', () => {
it('mappe uniquement les huit zones anatomiquement localisables et documente les agrégats', () => {
@ -10,6 +11,55 @@ describe('silhouette BODY ZONES canonique', () => {
expect(new Set(Object.values(BODY_ZONE_SVG_MAP).flat()).size).toBe(Object.values(BODY_ZONE_SVG_MAP).flat().length)
})
it('porte la tête, le cou et les onze chemins Android de chaque face', () => {
expect(ANDROID_BODY_HEAD).toEqual({ cx: 50, cy: 8.25, rx: 9.5, ry: 6.75 })
expect(ANDROID_BODY_NECK).toBe('M44.5 13 L43 19 C46 20.5 54 20.5 57 19 L55.5 13 Z')
expect(ANDROID_BODY_REGIONS.front).toHaveLength(11)
expect(ANDROID_BODY_REGIONS.back).toHaveLength(11)
const all = [...ANDROID_BODY_REGIONS.front, ...ANDROID_BODY_REGIONS.back]
const mappedIds = Object.values(BODY_ZONE_SVG_MAP).flat()
expect(new Set(all.map((region) => region.id)).size).toBe(22)
expect(new Set(mappedIds)).toEqual(new Set(all.map((region) => region.id)))
for (const region of ANDROID_BODY_REGIONS.front) {
expect(region.id).toMatch(/^front-/)
expect(BODY_ZONE_SVG_MAP[region.zone]).toContain(region.id)
}
for (const region of ANDROID_BODY_REGIONS.back) {
expect(region.id).toMatch(/^back-/)
expect(BODY_ZONE_SVG_MAP[region.zone]).toContain(region.id)
}
expect(ANDROID_BODY_REGIONS.front.find((region) => region.zone === 'chest')?.d)
.toBe('M27 28.5 C32 24.5 40 24 49 26.5 L49 38 C42 39.5 34 38 29 34.5 Z')
expect(ANDROID_BODY_REGIONS.back.find((region) => region.zone === 'back')?.d)
.toBe('M28 28.5 C35 25 43 24.5 50 26.5 C57 24.5 65 25 72 28.5 C68 38 64 47 62 55 C56 57 44 57 38 55 C36 47 32 38 28 28.5 Z')
})
it('rend les deux silhouettes neutres sans données en conservant la légende', () => {
const { container } = render(<BodyZoneFigure zones={[]} />)
expect(screen.getByLabelText('Silhouette, vue avant')).toBeInTheDocument()
expect(screen.getByLabelText('Silhouette, vue arrière')).toBeInTheDocument()
expect(container.querySelectorAll('path.body-region')).toHaveLength(22)
expect(container.querySelectorAll('path.body-region.intensity-0')).toHaveLength(22)
expect(screen.getByLabelText('Échelle visuelle des séances sur 30 jours'))
.toHaveTextContent('0FaibleIntermédiaireMaximum observé')
})
it('colore les régions correctes sur chaque face sans inverser avant et arrière', () => {
const zones = [
{ zone_id: 'chest', label: 'Pectoraux', session_count: 1, occurrence_count: 1, set_count: 1 },
{ zone_id: 'back', label: 'Dos', session_count: 2, occurrence_count: 2, set_count: 2 },
{ zone_id: 'glutes', label: 'Fessiers', session_count: 3, occurrence_count: 3, set_count: 3 },
]
const { container } = render(<BodyZoneFigure zones={zones} />)
expect(container.querySelector('#front-chest-left')).toHaveClass('intensity-1')
expect(container.querySelector('#front-chest-right')).toHaveClass('intensity-1')
expect(container.querySelector('#back-trunk')).toHaveClass('intensity-2')
expect(container.querySelector('#back-glute-left')).toHaveClass('intensity-3')
expect(container.querySelector('#front-core')).toHaveClass('intensity-0')
expect(container.querySelector('#front-chest-left')?.closest('svg')).toHaveAccessibleName('Silhouette, vue avant')
expect(container.querySelector('#back-trunk')?.closest('svg')).toHaveAccessibleName('Silhouette, vue arrière')
})
it('détermine la couleur uniquement depuis session_count et le maximum observé', () => {
expect(muscleIntensityLevel(0, 4)).toBe(0)
expect(muscleIntensityLevel(1, 4)).toBe(1)
@ -28,7 +78,7 @@ describe('silhouette BODY ZONES canonique', () => {
expect(chest).toHaveAccessibleName('Pectoraux : 4 séances, 6 occurrences, 18 séries.')
fireEvent.focus(chest)
expect(screen.getByRole('status')).toHaveTextContent('Pectoraux4 séances · 6 occurrences · 18 séries')
expect(container.querySelector('#back-upper')).toHaveClass('intensity-3')
expect(container.querySelector('#back-trunk')).toHaveClass('intensity-3')
})
it('rend une région absente neutre et non interactive', () => {

Some files were not shown because too many files have changed in this diff Show more