Compare commits

...

42 commits
v0.1.4 ... main

Author SHA1 Message Date
fy59
9a8708c63b release: prepare v0.1.6 2026-09-25 04:57:10 +02:00
fy59
bb20aa44ef docs: redesign README with Catppuccin identity 2026-09-25 04:43:41 +02:00
fy59
45bb44e45d fix(cardio): truncate recovered session capture at factual end 2026-09-24 18:36:02 +02:00
fy59
bd795495df fix(sync): recover divergent sleep revision identities 2026-09-24 14:31:53 +02:00
fy59
a652644e24 fix(android): recover continuous session finalization 2026-09-24 13:29:30 +02:00
fy59
8646422bef fix(web): anchor sleep rows to factual timezone 2026-09-24 08:42:31 +02:00
fy59
77b5bbca85 fix(web): center sleep hours and refine date controls 2026-09-24 08:35:13 +02:00
fy59
ffcdc3efcf fix(web): align sleep axis and range PDF export 2026-09-24 08:18:53 +02:00
fy59
23717ba6da fix(sleep): record wake taps as timed intervals 2026-09-24 07:54:29 +02:00
fy59
de4b77d59b fix(web): synchronize sleep selection and PDF projection 2026-09-24 07:16:47 +02:00
fy59
4cb911d5c4 docs: record sleep persistence and agenda evidence 2026-09-24 06:54:11 +02:00
fy59
a60e1aa056 fix(web): reuse sleep projection in agenda timeline 2026-09-24 06:52:10 +02:00
fy59
696dac82a3 test(android): prove sleep intake lifecycle retention 2026-09-24 06:52:03 +02:00
fy59
fe79c44e51 docs(web): record sleep overlay private deployment 2026-09-24 06:31:34 +02:00
fy59
2ce458d286 feat(web): enrich sleep heart-rate timeline overlays 2026-09-24 06:29:57 +02:00
fy59
3a51211f01 fix(sync): restore automatic sleep bluetooth synchronization 2026-09-24 06:00:29 +02:00
fy59
2755d29f2d docs(web): record post-review validation and private deployment 2026-09-24 05:10:33 +02:00
fy59
713238fd0e refactor(web): finish post-review layout and visual corrections 2026-09-24 05:09:54 +02:00
fy59
d7ca6a1924 fix(sync): recover sleep sync and canonical web mutations 2026-09-24 05:05:38 +02:00
fy59
e7fbd6ff86 refactor(web): refine dashboard analysis and settings UI 2026-09-23 22:14:25 +02:00
fy59
7adeb59b06 feat(web): add factual equipment merge and analysis aggregates 2026-09-23 22:14:25 +02:00
fy59
f20e5c4d39 chore: open 0.1.6 development 2026-09-23 21:20:38 +02:00
fy59
f2747b357b release: prepare v0.1.5 2026-09-23 21:10:11 +02:00
fy59
f2fd610b4f feat(sleep): complete pre-bed capture and multi-night cardio review 2026-09-23 20:48:06 +02:00
fy59
c8bfb7a759 test(cardio): prove multi-occurrence program heart-rate ownership 2026-09-23 19:02:59 +02:00
fy59
21f56e3986 test(cardio): prove program session heart-rate timeline readiness 2026-09-23 18:54:29 +02:00
fy59
5a7f80049a fix(cardio): keep live capture coherent across session type changes 2026-09-23 18:14:45 +02:00
fy59
41d0f1ff96 fix(sync): carry ack recovery over transports 2026-09-23 16:40:20 +02:00
fy59
0750a47cef fix(sync): recover lost generation acknowledgements 2026-09-23 16:19:17 +02:00
fy59
8f8b361915 docs(release): record 0.1.5 validation 2026-09-23 15:58:09 +02:00
fy59
246e47ee8f feat(web): visualize synchronized heart rate 2026-09-23 15:24:16 +02:00
fy59
ad493a72aa feat(cardio): guide phases from measured heart rate 2026-09-23 14:42:59 +02:00
fy59
8371d5e16c feat(cardio): add measured calibration profile 2026-09-23 13:20:00 +02:00
fy59
3ded6ee425 feat(cardio): add cardio session type 2026-09-23 12:34:01 +02:00
fy59
070a29298f feat(android): add one-tap sleep capture 2026-09-23 11:47:32 +02:00
fy59
dd39c85f94 feat(training): timestamp exercise execution 2026-09-23 11:32:15 +02:00
fy59
1ca354af53 feat(android): add persistent heart rate capture 2026-09-23 10:33:02 +02:00
fy59
5bf90899bd test(cardio): validate CYCPLUS H2 BLE profile 2026-09-23 10:13:26 +02:00
fy59
984b1e058e feat(android): add heart rate BLE diagnostics 2026-09-23 10:07:24 +02:00
fy59
14c3ac294e feat(cardio): add heart rate persistence and sync 2026-09-23 09:40:05 +02:00
fy59
c13549338c docs(cardio): freeze v1 implementation contract 2026-09-23 09:26:14 +02:00
fy59
e1d6372338 chore: open 0.1.5 development 2026-09-22 18:45:49 +02:00
216 changed files with 24104 additions and 1087 deletions

View file

@ -1,5 +1,248 @@
# Changelog
## 0.1.6 — 2026-09-25
- Corrected delayed session finalization so a heart-rate capture cannot extend
beyond the explicit factual session end. Android now transactionally removes
strict post-end BPM/RR tails while retaining an exact-cutoff sample. For an
already acknowledged capture, the separately versioned
`trainlog-heart-rate-corrections` V1 companion carries an immutable
`capture_id|cutoff` correction through the existing causal ledger without
widening frozen causal-deletions V1. Old Heart Rate V1 generation replay
applies that cutoff before identity comparison and cannot resurrect removed
measurements. A guarded dry-run recovery tool reports before/after counts and
statistics without rewriting published manifests or ACKs.
- Completed the paired-device recovery for session
`se_fa845379-794d-4bb6-8cd3-48187ebbd134`: Android and desktop now retain
3,429 BPM samples and 3,429 RR values through the inclusive 11:16 cutoff,
and two successive full MTP generation exchanges completed with durable
consumed ACKs. Replaying the original 4,919-sample artifact cannot restore
the removed tail. Generation parent selection now excludes archived or
legacy acknowledgement rows lacking durable ACK evidence, while consumers
accept the producer's declared parent when it is one of multiple retained
current tips. Android Sleep V2 persistence also names medication-intake
columns explicitly, preserving `quantity`, note, and creation time across
the additive v33 layout.
- Recovered a real divergent Sleep revision identity without choosing an
arbitrary winner. The unpublished desktop branch was re-identified with its
exact content, the immutable Android revision retained its published ID and
parent, and a fresh successor preserved the non-conflicting qualities,
events and medication occurrences. The original immutable generation now
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
session is available through Desktop and Web with its timeline, feedback,
cardio and Program provenance.
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
revision payloads. Both importers now reject a known historical revision ID
whose parent or canonical content differs, even when that revision is below
the current tip. A dedicated transactional recovery utility and regressions
cover exact replay, parent/content collisions, medication quantities,
rollback, immutable generation retry, and long UUID/parent chains.
- Fixed Android finalization of Program-derived sessions containing a completed
target-only continuous occurrence. The repository now materializes the
performed duration from the persisted exercise timeline, including preserved
drafts closed by an older build, while keeping planned set counts independent
from valid actual sets, repetitions and loads. A strictly identity-bound
private recovery build may supply the factual session end; strict late
heart-rate BPM/RR tails are now removed transactionally at that boundary.
- Hardened desktop Sleep generation consumption when an identical durable
revision has no local publication row. Content comparison now reads the
durable revision directly, acknowledges an exact match, and reports a real
revision-identity conflict instead of an empty rejection diagnostic when the
same ID carries different content.
Development opened after the stable v0.1.5 release. The Web refinement tranche
adds persistent French/English settings, denser shared surfaces, ISO-week
Dashboard activity, factual exercise-count overview charts, measured-timeline
muscle-group drill-down, normalized measurement summaries, one shared refined
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
lists factual references and transactionally absorbs a custom duplicate into a
canonical identity while retaining history/MAX context and recording a causal
deletion marker; immutable embedded drafts cause an explicit conflict.
The post-review correction keeps acknowledged-generation capacity recoverable
for legacy Android rows without ACK-ledger evidence, proves two successive
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
generation. Web can now causally remove only an orphaned zero-occurrence active
draft while retaining protection for real active drafts. Mutation UUIDs use
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
Dashboard sizing, Program progress contrast, chart proportions, duration
coverage messaging, and the right-aligned Settings/Sync action cluster are
also refined.
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
trace exposed several restart and first-import gaps. The user-service
installer now removes only its obsolete `syncd` ExecStart override and
installs the RFCOMM agent;
Android resumes the same unsuperseded immutable generation across a new
conversation run and accepts its exact generation/digest ACK even though
the immutable ACK retains the original run identity. Ledger-proven
superseded branches remain retained without exhausting admission. Desktop
progress validation accepts the existing ACK-recovery phase, and a fresh
desktop can seed a non-root Sleep V2 current snapshot when its validated
ancestry is present. Regression
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
through import, ACK, replay and a second Sleep-only generation with no
Android workout session.
- Enriched the selected Sleep night's heart-rate chart with declared sleep
ranges, explicit awakening ranges and point events, medication markers that
preserve unit dose and quantity, exact start/end bounds, and every
intervening hourly tick. When no declared sleep interval exists, the Web
presentation alone shows the conservative bedtime +45 minutes to final
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
writes it to Sleep data. Nights without synchronized HR retain these factual
and estimated timeline overlays without inventing BPM samples. The private
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
real HR night and the latest real night containing medications from code
commit `2ce458d`.
- Reused that same read-only fallback projection in the multi-night Sleep
agenda, with a visibly estimated hatched interval and no Sleep write. The
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
two-hour labels on ordinary desktop widths, expands to every hour when wide,
and reduces to six-hour anchors on narrow screens. A read-only real-device
investigation of the 2026-09-23 night found no medication intake in any
retained local revision and established that later synchronization only
published the already-empty current revision. Android regression coverage
now proves two pre-bed intakes, including quantity two, through repeated
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
replay, stale-ancestor input and a subsequent revision. The private service
serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real
night confirms the identical 22:50–04:04 agenda/detail projection and the
responsive 13/25/5-label axis densities.
- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one
temporal projection. Every structured long-awake interval remains a distinct
band, row selection updates the detail dates and content without persistence,
and preview/download export exactly the active night with observations
recomputed for that subset. Sleep duration now consistently sums factual
sleep intervals or, only when none exist, uses the displayed Couché +45 min
to Levé -10 min estimate minus structured awakenings; the estimate is never
written to SQLite.
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
facts to structured `long_awake` windows under the user-approved 30-minute
default. A repeated tap inside the window extends its end to 30 minutes after
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
The policy is revisioned, undoable, and independent of BPM/RR inference.
- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid
and added an inclusive night-start date range for PDF preview and export.
The duplicate terminal `18` is removed; detail/HR night selection remains
independent from the report range, whose date fields now use a grouped
Trainlog form treatment.
- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but
factual events used the user's local offset: the row now anchors its complete
18:00 axis to one factual night offset. Agenda night headings use French
`DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI.
## 0.1.5 — 2026-09-23
- Completed the Sleep Tonight readiness delta and its non-destructive private
vc47 rollout. Pre-bed medication now creates or
reuses one durable pending 18:00-to-18:00 Sleep entry; Couché appends bedtime
to that same stable identity, while Réveil/Levé remain unavailable before
bedtime. Pending quick facts are publishable through a distinct marker and
are not day validation.
- Added structured per-intake medication quantity (`1..99`) in Android schema
v32 and desktop schema v35. The active `trainlog-sleep-diary` V2 companion
(`sleep-diary-v2.json`) carries quantity; frozen V1 remains readable and
interprets missing quantity as one. Per-unit dose/unit remain separate from
quantity, which is rendered in Android, Web and PDF as appropriate (for
example `75 mg ×2`).
- Kept sleep HR capture factual and sample-driven: pre-bed medication alone
creates no capture; the first real post-Couché sample opens/reuses the
entry-owned capture at bedtime; Réveil does not split it; and Levé closes it
exactly at final get-up. A no-sensor night fabricates neither capture nor
sample.
- Updated the Web Sleep agenda to render every returned night in the selected
period on one shared 18:00-to-18:00 axis. Row selection is navigation only
and shows the selected entry’s full-width HR detail. Relative measured HR
rises/falls use a trailing five-minute reference excluding the current
60-second window, thresholds of `max(10 BPM, 15%)`, and ignore gaps over
three seconds; labels remain descriptive and make no sleep-stage, medical,
or causal inference.
- Added the Cardio V1 persistence/synchronization foundation. Android schema
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
transactionally. Exact generation membership is retained until a correlated
consumed ACK, after which those stopped captures remain local history but are
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
only; Bluetooth Classic Trainlog synchronization is unchanged.
- Added the Android Heart Rate Service diagnostic foundation. The independent
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
strict malformed-payload rejection. A settings diagnostic provides bounded
HRS-filtered and expanded BLE scans, GATT service discovery and notification
subscription without turning desktop/Web into sensor clients. Android 12+
scan/connect permissions and the Android 11-and-older scan-location boundary
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
indicator. A validated sensor is remembered, a dedicated connected-device
foreground service reconnects across app restarts and Bluetooth loss, stale
measurements are cleared instead of displayed indefinitely, and the heart
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
recovery on the private 0.1.5 versionCode 40 installation.
- Added the training execution timeline used for cardio correlation. Android
schema v28 records explicit start/end actions for stable exercise occurrences
while preserving the session lifecycle `started_at`/`ended_at` as canonical
bounds. Only one exercise can be active; session finalization refuses an
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
rest intervals remain unowned, and the optional `trainlog-session-timeline`
V1 companion imports idempotently into desktop schema v31.
- Added Android one-tap Sleep capture on top of the existing Sleep Diary V1:
Couché, active medication buttons, point Réveil events and Levé each record
the current timestamp in one action. A guarded short-lived Annuler creates a
causal compensating revision, while Corriger la nuit retains the complete
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
- Added the true `cardio` session type without widening frozen session
codecs. Android schema v29 and desktop schema v32 introduce canonical
`session_kind=training|max_test|cardio` while the historical
`session_type` compatibility column remains `training|max_test`.
Completed cardio sessions are excluded from `history-v4` and move through
the separate optional `trainlog-cardio-sessions` V1 generation companion;
exact replay is idempotent and identity collision with non-cardio history is
rejected.
- Added the first versioned Cardio calibration workflow. Android schema v30 creates one reserved system exercise outside the normal exercise catalogue, starts a real cardio session and HR capture, records only measured observed peak BPM, and requires at least three minutes of recovery. +1/+2/+3 minute recovery facts accept only real samples within a bounded ten-second window; missing signal therefore remains missing instead of being backfilled from a later reconnect. Completed immutable profiles synchronize Android→desktop through `trainlog-cardio-calibrations` V1 into desktop schema v33, where the importer proves peak and recovery points against the synchronized raw HR capture before accepting them.
- Added fresh-signal BPM-guided cardio phases. Android schema v31 stores
ordered phase runs against the active cardio occurrence, immutable resolved
BPM targets, optional exact calibration provenance, exit conditions and
instruction changes. The engine uses a 2 BPM hysteresis band and a 3-second
confirmation interval to avoid boundary oscillation; stale signal suspends
guidance immediately and never reuses an old BPM. Android exposes live
Accélère / Maintiens / Ralentis guidance during the active cardio exercise.
Completed runs synchronize once through `trainlog-cardio-guidance` V1 into
desktop schema v34. Desktop verifies cardio-session ownership, exercise
timeline, calibration provenance and exact raw HR evidence for instruction
events carrying BPM before accepting them.
- Fixed live Cardio guidance after changing an already-started manual draft
from Training to Cardio. Draft persistence now atomically reconciles the
same active heart-rate capture from `session` to `cardio` without replacing
its identity or losing existing BPM/RR samples and sequence. A transition
away from Cardio remains possible before Cardio-only facts exist, but is
rejected once durable guidance or calibration facts would make it
ambiguous. Repository coverage reproduces the real ordering through an
active exercise and guidance event. The private vc46 re-smoke retained the
original capture and more than 31 existing samples across the transition,
then displayed a measured 92 BPM and `ACCÉLÈRE`; the Sommeil and Calibration
cardio screens also rendered without creating persistent data.
- Added one end-to-end tomorrow-session readiness regression. A synchronized
Program session retains its stable session and Program identities through
edits, two ordered Program-derived occurrence intervals,
pre-exercise/inter-exercise/active heart-rate ownership, finalization,
full-generation publication, desktop import, replay and correlated ACK. Raw
observation timestamps, BPM, RR, nullable stable exercise ownership and
capture/session bounds are compared on both platforms without inferring
ownership from time overlap.
- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added.
Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22
- Added the frozen Bluetooth Classic RFCOMM synchronization transport without

570
README.md
View file

@ -1,376 +1,124 @@
# Trainlog
<p align="center">
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
</p>
Synchronization uses one generation/ACK business engine across versioned
local transports. The paired 0.1.4 deployment prefers Bluetooth Classic RFCOMM
and falls back to direct USB/MTP; the separately configured private Drive path
remains available to the legacy mirror/fallback workflow. No transport copies
SQLite databases between devices.
<p align="center">
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
</p>
Trainlog is a local-first workout and body-data system. Trainlog Core owns
business truth and canonical desktop persistence. Its native Android app is the
field companion, its C17/Notcurses desktop TUI is the administration and
technical surface, and its local browser Web sibling provides the implemented
Dashboard, Sessions, Programmes, Exercises, and factual Analyse/Sleep Diary
surfaces.
Trainlog is a **local-first system for recording, synchronizing, and examining
training and body data**. Its Android companion handles capture in the field;
the C17 desktop core keeps the canonical long-term history; a keyboard-first
Notcurses TUI and a loopback-only Web application provide complementary ways
to inspect and manage it.
Android and desktop each own a local SQLite database. The desktop database is
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
over its authenticated local transports and optional configured Drive workflow;
it never copies SQLite database files between devices.
The system is built around explicit facts, stable identities, versioned JSON
exchanges, and user-controlled storage. Plans never become performed work until
they are actually recorded, continuous activities are not disguised as sets,
and estimates are kept distinct from measurements.
## Product split
> **Release line:** The latest stable release is
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
| Surface | Primary responsibility |
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.4) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, 18:00-to-18:00 agenda and local vector PDF export. |
## ✦ Showcase
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
visualizations, and private layout persistence are implemented. This does not
make the remaining placeholder routes functional.
The top-level `/programmes` route is an operational daily calendar for active
Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration is implemented. Version 0.1.4 also includes the
factual Dashboard/Analyse read model and the synchronized Sleep Diary workspace.
<p align="center">
<img src="docs/reviews/evidence/sleep-diary-alignment-firefox.png" alt="Trainlog Web Analyse workspace showing the Sleep Diary agenda and factual heart-rate timeline" width="100%">
</p>
## Releases
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
When a Trainlog release is published, its prebuilt assets are available from
both official mirrors:
The repository currently tracks real-browser evidence for the Web interface.
Its Android overview asset is a clearly labelled static design study with
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
Those assets are therefore not presented here as product captures.
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
## ✦ Features
Each published stable release provides:
| Area | Available in the current development tree |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Training capture** | Metadata-driven sets, repetitions, durations and continuous activities; heterogeneous actual sets; durable active draft; exercise timelines; explicit measured MAX results. |
| **Planning** | Manual preparations, imported Programs, Android delivery, execution provenance and strict separation between targets and facts. |
| **Context** | Exercise and equipment catalogues, BODY ZONES, body measurements, immediate feedback, wording revisions and J+1 follow-ups. |
| **Cardio** | Android BLE heart-rate acquisition, factual BPM/RR history, cardio sessions, measured calibration and fresh-signal phase guidance. |
| **Sleep** | One-tap Android capture, causal Sleep Diary revisions, medication occurrences, synchronized factual timelines and local vector PDF export. |
| **Desktop** | C17 Core, canonical SQLite history, keyboard-first Notcurses administration, correction, statistics, graphs and synchronization diagnostics. |
| **Local Web** | Loopback-only Dashboard, Analyse, Programmes, Sessions, Exercises, Equipment, Settings and synchronization surfaces backed by typed Core services. |
| **Synchronization** | One generation/ACK business engine over authenticated Bluetooth Classic RFCOMM, direct USB/MTP recovery and a separately configured private Drive workflow; SQLite files are never exchanged. |
- a signed Android APK;
- a Linux x86-64 desktop runtime bundle;
- SHA-256 checksums.
### Boundaries and work in progress
Both mirrors publish the same Trainlog product version and release assets. The
latest stable version is **0.1.4** on Android and desktop; this is one shared
Trainlog version, not separate interface versions. Version 0.1.3 is the
previous stable release.
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
and V2 remain readable, while V4 codecs are staged but not selected by the
active transport.
- Full-generation synchronization is deployed only behind a trusted local
opt-in; the default automatic exchange remains V3.
- AI history export is active. AI session proposals still have a pending real
Drive plus Android-triggered bidirectional smoke test.
- Session Generator V1 is implemented but hidden while the V2 planning model
is developed.
- Web Analyse is implemented; its recorded visual-review gate remains open.
Trainlog presents French by default, with English selectable
from **Settings → Language** on Android and the desktop TUI. The selection is
local to that installation and updates the visible interface immediately; it
does not translate user exercise/catalogue names or change training data,
stable IDs, databases, schemas, exchange artifacts, synchronization protocols,
AI, MAX, or feedback. Synchronization status is rendered locally from typed
status and counters; raw protocol and operational summaries are not translated
or injected into the other surface.
The canonical status and limitations live in
[`docs/current_state.md`](docs/current_state.md); future work belongs in the
[`roadmap`](docs/roadmap.md).
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
details.
## Installation
### Download a prebuilt release
For a published release, download Trainlog from either official mirror:
- [GitHub](https://github.com/labfytools/trainlog/releases)
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
Both mirrors contain the same Android APK, Linux x86-64 runtime bundle, and SHA-256
checksums for each stable Trainlog release. Verify the downloaded binaries
against `SHA256SUMS` before installing them.
The Linux artifact is an architecture-specific tar archive whose published
filename intentionally remains `trainlog-tui-linux-x86_64-v<version>`. It
contains the TUI/Web executable, synchronization daemon and helpers, catalogs,
and a hashed inventory from the same release commit. Its native executables are
dynamically linked, so the host must provide compatible runtime libraries
listed under [Dependencies](#dependencies).
```bash
mkdir trainlog-v<version>
tar -xf trainlog-tui-linux-x86_64-v<version> -C trainlog-v<version>
./trainlog-v<version>/bin/trainlog
```
The extracted bundle can remain in a user-managed installation directory.
Linking its launchers from `bin/` into `~/.local/bin` is optional.
For Android, download the release APK, allow installation from the browser or
file manager when Android requests it, and install the package. Android 8.0
(API 26) or later is required. Grant Trainlog all-files access only when direct
`Documents/Trainlog` synchronization is needed. Desktop synchronization also
requires the Linux side and its MTP dependencies to be installed and configured.
Android and the TUI in each stable release share the same Trainlog product
version on both mirrors. Database schemas and JSON protocol versions are
independent.
### Daily synchronization on an opted-in installation
After explicitly enabling the paired local synchronization path, keep Bluetooth
enabled on Android and the desktop and select **Synchronize** in Trainlog when
an explicit run is wanted. The current paired deployment prefers authenticated
Bluetooth Classic RFCOMM and uses direct USB/MTP as the wired recovery path.
The existing private Drive workflow remains separately configured. Wait until
the correlated generation and acknowledgements complete; a failure remains
durable and should be diagnosed before retrying rather than deleting protocol,
database, or backup files.
### Build from source
Desktop:
```bash
meson setup build
meson compile -C build
meson test -C build --print-errorlogs
```
Android:
```bash
cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
```
`assembleRelease` produces a distributable stable APK only when release signing
is configured by the release operator. The repository does not contain signing
credentials and does not silently generate them.
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
`~/.config/trainlog/release-signing.properties` file containing the equivalent
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
variables take precedence. Keystores and local credential files are ignored by
Git; neither belongs in source control, build logs, or release notes.
The Android release key is the permanent update identity for the application.
The release operator must preserve an encrypted external backup of both the
keystore and its credentials. Losing either prevents future compatible Android
updates; generating a replacement key is not a normal release procedure.
## Dependencies
### Linux runtime dependencies
The published v0.1.4 x86-64 desktop runtime is dynamically linked. Its tagged
source directly requires compatible versions of:
- glibc and the GCC support runtime;
- SQLite 3;
- libuuid;
- utf8proc;
- libudev;
- libmtp;
- Notcurses Core;
- the standard math library.
Version 0.1.4 additionally links GNU libmicrohttpd and yyjson for the local Web
adapter and Dashboard layout configuration. Those are not retroactive
requirements of the pre-Web v0.1.1 tagged source.
Distribution packages may pull additional transitive libraries, including
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
Package names and ABI versions vary by distribution; inspect the release's
`ldd` evidence and install packages from the target distribution rather than
assuming one universal package command.
### Optional runtime and integration dependencies
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
requires the Android companion, libudev, libmtp, and an unlocked connected
device.
- Bluetooth synchronization requires BlueZ on Linux plus Python 3.11 or newer
with D-Bus and GObject bindings. It is a Trainlog synchronization transport,
not a heart-rate sensor implementation.
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
synchronization does not require the user service.
- `rclone` is required for desktop Google Drive synchronization and automatic
AI Drive workflows. Core capture, local history, analytics, and direct MTP
synchronization continue to work without `rclone`.
### Build dependencies
Desktop builds require:
- Meson and Ninja;
- a C17 compiler toolchain;
- `pkg-config`;
- Notcurses Core development headers;
- SQLite, libuuid, utf8proc, libudev, libmtp, GNU libmicrohttpd, and yyjson
development headers for the current development source;
- Python 3 for generated sources, validators, import/export helpers, and tests.
The embedded Web frontend additionally uses Node and npm at build time only.
Prepare its exact locked dependencies explicitly, then enable the release
frontend build:
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build -Dweb=enabled
meson compile -C build
```
Meson never runs `npm install` or downloads packages. Run `npm ci` explicitly
for the exact lockfile before configuring a Web-enabled build. The default `web=auto`
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
available; otherwise it preserves a desktop-only build and `trainlog -w`
reports that frontend support is absent. Release builds must use
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
npm, `node_modules`, and `web/dist` are not runtime dependencies.
For an existing build directory, change the option with `meson configure
build -Dweb=enabled` (or the required value) instead of running `meson setup`
as though the directory were new.
### Optional systemd user service for local Web development
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
available on `127.0.0.1:8080` during an interactive user session. Without
systemd user lingering, enabling it does not guarantee startup before login.
This is a development convenience, not a requirement of the final Trainlog
distribution.
It intentionally runs the canonical user entry point
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
executable before the service is started.
The unit prevents privilege acquisition, gives the process a private temporary
directory, makes system paths read-only, and applies kernel/control-group and
setuid restrictions that are compatible with the local HTTP server. It does
not protect or hide the home directory because SQLite and Trainlog
configuration must remain writable under `~/.local/share/trainlog` and
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
limited to three attempts per minute, preventing a persistent port-8080
conflict from producing an uncontrolled loop.
Install the development unit as a symlink so repository updates are picked up
after `daemon-reload`:
```bash
mkdir -p ~/.config/systemd/user
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
systemctl --user enable --now trainlog-web.service
```
Useful lifecycle and diagnostic commands are:
```bash
systemctl --user status trainlog-web
systemctl --user restart trainlog-web
systemctl --user stop trainlog-web
systemctl --user start trainlog-web
journalctl --user -u trainlog-web
```
An operator may separately configure a local nginx proxy such as
`http://trainlog.perf`. That hostname and proxy are machine-local conveniences,
not a public Trainlog domain or a runtime dependency, and Trainlog does not
configure them automatically.
Uninstalling the integration disables the unit before removing only its user
configuration symlink:
```bash
systemctl --user disable --now trainlog-web.service
rm ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
```
Android builds require JDK 17, an Android SDK supporting the configured API
levels, and the Gradle wrapper committed in this repository. People installing
the APK do not need Java, Gradle, or the Android SDK.
## Documentation
| Topic | Document | Purpose |
|---|---|---|
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
## Architecture overview
## ✦ Architecture
```text
Android local SQLite
|
| versioned JSON snapshots and requests
v
Documents/Trainlog on Android storage
|
| direct MTP / libmtp
v
trainlog_sync_run() <---- trainlog-syncd or desktop TUI
|
+---- import Android snapshot into desktop SQLite
+---- publish desktop catalog companions to Android
+---- write a synchronization receipt
desktop SQLite = canonical long-term history
┌──────────────────────────────┐
│ Android field companion │
│ private SQLite capture store │
└──────────────┬───────────────┘
│ versioned JSON generations + acknowledgements
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
▼
┌──────────────────────────────┐ optional, separately configured
│ Synchronization orchestration│◄───── private Drive workflow
└──────────────┬───────────────┘
│ typed imports, commands and read models
▼
┌──────────────────────────────┐
│ Trainlog Core · C17 │
│ desktop SQLite = canonical │
│ long-term history │
└──────────────┬───────────────┘
│
┌──────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
└──────────────┘ └──────────────────────┘
```
The optional desktop AI flow exports read-only history and exchanges session
proposals through Google Drive via the external `rclone` process. Android does
not contain cloud credentials and does not run `rclone`.
The TUI, Web application, and Android client are sibling adapters with
different responsibilities. Business rules, calculations, persistence, and
typed read models belong to Core; presentation code does not reconstruct them
from raw database tables. See the full
[`architecture contract`](docs/architecture.md).
## Current status
## ✦ Your data, your system
As of 2026-09-18:
- Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
account or proprietary cloud service is required for core capture, history,
analytics, or direct synchronization.
- Android and desktop each keep a local SQLite database; the desktop database
is the canonical long-term history.
- Devices exchange bounded, versioned JSON artifacts—not database files.
- The local Web server binds to `127.0.0.1` by default and never silently falls
back to a LAN address or another port.
- Direct Android storage uses `Documents/Trainlog`; desktop USB access uses
`libudev` and `libmtp` without requiring a GVFS/FUSE mount.
- The optional Drive path is operator-configured through external `rclone`.
Android holds no cloud credentials.
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v29 and Android SQLite schema v26;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
history and execution-draft codecs are staged but not selected by transport;
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
`/programmes` is an operational active-Program calendar, while
Sessions → Programmes remains the administration surface;
`TRAINLOG_WEB_EXERCISES_V1=PASS`; `/analyse` now provides the 0.1.4 factual
Dashboard/Analyse read-model foundation pending visual review;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The trusted local
opt-in path is deployed on the paired private installation and has completed
real direct-MTP restart/replay validation; it is not a public rollout.
## ✦ Build and run
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.
### Desktop and TUI
## Desktop development quick start
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
libmtp, and Notcurses.
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
Notcurses Core, GNU libmicrohttpd, and yyjson.
```bash
meson setup build
@ -379,13 +127,36 @@ meson test -C build --print-errorlogs
./build/tui/trainlog
```
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
The desktop database is stored at
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
## Android development quick start
### Embedded local Web application
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
`android/local.properties` file.
Node and npm are build-time dependencies only. Meson does not download npm
packages.
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build-web -Dweb=enabled
meson compile -C build-web
./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.
### Android
Android builds use the committed Gradle wrapper, JDK 17, and an Android SDK for
the configured API levels (`minSdk 26`, `targetSdk 36`, `compileSdk 37`).
```bash
cd android
@ -393,38 +164,81 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
```
Instrumented tests belong on an emulator. Do not run
`connectedDebugAndroidTest` on the primary personal phone.
Instrumented tests belong on an emulator; the primary personal phone is
reserved for manual, non-destructive installation and synchronization checks.
Release APK signing is intentionally operator-local and is not configured with
credentials from this repository.
## Synchronization quick start
<details>
<summary>Optional user-session services</summary>
After a desktop build, the repository provides an installer for the private
automatic synchronization services:
```bash
meson compile -C build
bash tools/install_syncd_user.sh
systemctl --user is-active trainlog-syncd.service
```
On Android, grant all-files access for the fixed `Documents/Trainlog`
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
validation requires an unlocked connected device and remains an explicit manual
check.
This modifies the current user's `~/.local/bin` and systemd user configuration.
Bluetooth generation transport starts only when its separate Trainlog
configuration explicitly enables it. The loopback Web development unit in
`systemd/trainlog-web.service` is another optional integration; neither service
is required for manual TUI use.
## Development principles
</details>
- Preserve frozen, versioned compatibility boundaries.
- Use stable IDs; display names are not identities.
- Keep performed work separate from plans and continuous activity separate
from fake sets.
- Keep Android capture, desktop analytics, persistence, transport, and
rendering responsibilities distinct.
- Prefer explicit failure to silent data loss or corruption.
- Treat migrations, idempotence, tests, and documentation as part of feature
completion.
- Keep scientific catalogs separate from runtime user data and preserve stated
uncertainty.
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
downloaded artifacts against the accompanying `SHA256SUMS`.
## License and project notes
## ✦ Repository layout
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
Repository layout, detailed contracts, and historical review records are linked
from [docs/README.md](docs/README.md).
```text
trainlog/
├── android/ Kotlin + Jetpack Compose field companion
├── tui/ C17 Core, Notcurses TUI, Web adapter and native tests
├── web/ React + TypeScript + Vite frontend
├── catalog/ versioned scientific and product knowledge
├── format/ JSON schemas and frozen format material
├── contracts/ machine-readable presentation contracts
├── tools/ validators, import/export and sync utilities
├── tests/ cross-surface and protocol regression tests
├── systemd/ optional user-session integration
└── docs/ canonical documentation and review evidence
```
## ✦ Documentation
| Start here | Scope |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| [`docs/current_state.md`](docs/current_state.md) | Implemented capabilities, versions, validation evidence and active limitations. |
| [`docs/architecture.md`](docs/architecture.md) | Component, storage, process, transport and ownership boundaries. |
| [`docs/roadmap.md`](docs/roadmap.md) | Current cursor and future work. |
| [`docs/android.md`](docs/android.md) | Android capture, persistence and synchronization behavior. |
| [`docs/tui.md`](docs/tui.md) | Notcurses workflows, correction, analytics and technical tools. |
| [`docs/sync_exchange.md`](docs/sync_exchange.md) | Active synchronization flow, replay and acknowledgement semantics. |
| [`docs/exchange_format.md`](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| [`docs/exercise_data_model.md`](docs/exercise_data_model.md) | Exercise, occurrence, planning, load and MAX semantics. |
| [`docs/database.md`](docs/database.md) | Desktop SQLite schema and migration contracts. |
| [`docs/tests.md`](docs/tests.md) | Canonical validation commands and coverage strategy. |
The complete ownership map is in [`docs/README.md`](docs/README.md), with
chronological detail in the [`CHANGELOG`](CHANGELOG.md).
## ✦ Project status
| Boundary | Current state |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Product | `0.1.6` stable |
| Desktop persistence | SQLite schema v36 |
| Android persistence | SQLite schema v33 |
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
| Desktop terminal | C17 + Notcurses, active backend |
| Local Web | Dashboard and operational Analyse, Programmes, Sessions, Exercises, Equipment, Settings and sync surfaces |
| Interface language | French by default; English selectable on Android and desktop |
| Compatibility | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
Forgejo is the primary repository; GitHub is its release mirror. Trainlog is
licensed under the [GNU General Public License v3](LICENSE).

View file

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

View file

@ -1,3 +1,4 @@
import java.time.OffsetDateTime
import java.util.Properties
plugins {
@ -43,7 +44,38 @@ val trainlogVersionCode =
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 5
} ?: 7
/*
* WHY: a user may need to finish one preserved draft after a completion bug
* has been repaired, while its factual end time predates the repair build.
* CONTRACT: both values are private-build inputs, must be supplied together,
* and are embedded only after strict identity/timestamp validation.
* INVARIANT: ordinary builds use empty values and every non-matching future
* session continues to finalize at the live clock time.
*/
val recoverySessionId =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
val recoveryEndedAt =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
throw GradleException(
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
)
}
if (recoverySessionId.isNotEmpty()) {
val sessionPattern =
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
if (!sessionPattern.matches(recoverySessionId)) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
}
try {
OffsetDateTime.parse(recoveryEndedAt)
} catch (_: Exception) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
}
}
android {
namespace = "com.labfytools.trainlog"
@ -55,7 +87,9 @@ android {
targetSdk = 36
versionCode = trainlogVersionCode
versionName = "0.1.4"
versionName = "0.1.6"
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
@ -86,6 +120,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
sourceSets {

View file

@ -12,6 +12,14 @@
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE cardio discovery does not derive physical location. Android 11 and
lower still require fine location for direct BLE scans. -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@ -42,5 +50,9 @@
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".data.HeartRateSensorService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>

View file

@ -16,7 +16,10 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.TrainlogApp
import com.labfytools.trainlog.ui.TrainlogAppState
import com.labfytools.trainlog.ui.LanguagePresentation
@ -39,6 +42,12 @@ class MainActivity : ComponentActivity() {
) {
SyncBackgroundService.start(applicationContext)
}
if (
HeartRateSensorPreferences(applicationContext).selected() != null &&
hasHeartRateBleRuntimePermissions(applicationContext)
) {
HeartRateSensorService.start(applicationContext)
}
val repository =
TrainlogRepository(

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..26
manifest.getInt("schema") in 17..33
) {
"Unsupported backup."
}
@ -319,7 +319,7 @@ internal class AndroidBackupService(
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -41,6 +41,17 @@ private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L
private const val MAX_BT_FILES = 128
private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L
internal val BLUETOOTH_ANDROID_PULL_COORDINATION =
listOf(
"android-peer-v1.json",
FULL_GENERATION_REQUEST_NAME,
LEGACY_SYNC_REQUEST_NAME,
"android-generation-v1.json",
"android-consumption-ack-v1.json",
"android-archive-acknowledgements-v1.json",
"android-generation-error-v1.json",
)
internal data class BondedBluetoothDesktop(
val name: String,
val address: String,
@ -90,6 +101,19 @@ internal fun hasBluetoothConnectPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
/**
* Decide whether a newly connected RFCOMM peer may publish an automatic request.
*
* WHY: Sleep-only changes have no workout event to wake the desktop path, so
* Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and
* the existing cooldown are both mandatory. INVARIANT: reconnection never
* bypasses the bounded request cadence or requires an explicit UI action.
*/
internal fun shouldPublishBluetoothArrivalRequest(
autoSyncEnabled: Boolean,
cooldownReady: Boolean,
): Boolean = autoSyncEnabled && cooldownReady
@SuppressLint("MissingPermission")
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
if (!hasBluetoothConnectPermission(context)) return emptyList()
@ -248,14 +272,7 @@ private class BluetoothExchangeArchive(
val totals = longArrayOf(0L, 0L)
try {
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
listOf(
"android-peer-v1.json",
FULL_GENERATION_REQUEST_NAME,
LEGACY_SYNC_REQUEST_NAME,
"android-generation-v1.json",
"android-consumption-ack-v1.json",
"android-generation-error-v1.json",
).forEach { name ->
BLUETOOTH_ANDROID_PULL_COORDINATION.forEach { name ->
addFile(zip, File(exchangeDirectory, name), name, seen, totals)
}
val reference = File(exchangeDirectory, "android-generation-v1.json")
@ -522,9 +539,15 @@ internal class BluetoothSyncClient(
}
private fun publishArrivalRequest() {
if (!BackgroundSyncSettings(context).enabled) return
val now = System.currentTimeMillis()
if (!settings.shouldPublishAutomaticRequest(now)) return
if (
!shouldPublishBluetoothArrivalRequest(
BackgroundSyncSettings(context).enabled,
settings.shouldPublishAutomaticRequest(now),
)
) {
return
}
val directory = canonicalExchangeDirectory()
if (!directory.isDirectory) return
val coordinator =

View file

@ -0,0 +1,190 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import kotlin.math.roundToInt
internal object CardioTargetResolver {
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
when (definition) {
is CardioTargetDefinition.AbsoluteBpm -> {
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
CardioTargetSnapshot(
minimumBpm = definition.minimumBpm,
maximumBpm = definition.maximumBpm,
)
}
is CardioTargetDefinition.CalibrationPercent -> {
if (
definition.calibrationId.isBlank() ||
definition.observedPeakBpm !in 1..65535 ||
definition.minimumPercent !in 1..100 ||
definition.maximumPercent !in 1..100 ||
definition.minimumPercent > definition.maximumPercent
) return null
val minimum =
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
.roundToInt()
val maximum =
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
.roundToInt()
if (!validBounds(minimum, maximum)) return null
CardioTargetSnapshot(
minimumBpm = minimum,
maximumBpm = maximum,
calibrationId = definition.calibrationId,
calibrationObservedPeakBpm = definition.observedPeakBpm,
minimumPercent = definition.minimumPercent,
maximumPercent = definition.maximumPercent,
)
}
}
private fun validBounds(minimum: Int, maximum: Int): Boolean =
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
}
/**
* Pure state machine for BPM guidance.
*
* Hysteresis and confirmation time are UI/control stability parameters, not
* physiological zones. The engine never extrapolates a stale measurement.
*/
internal object CardioPhaseCompletion {
fun isComplete(
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
elapsedSeconds: Int,
bpm: Int?,
fresh: Boolean,
): Boolean {
if (elapsedSeconds < 0) return false
return when (val exit = phase.exitCondition) {
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
elapsedSeconds >= exit.seconds
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
val target = phase.target ?: return false
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
}
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
fresh && bpm != null && bpm <= exit.bpm
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
elapsedSeconds >= exit.maximumSeconds ||
(fresh && bpm != null && bpm <= exit.bpm)
}
}
}
internal class CardioGuidanceEngine(
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
) {
private var current =
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
?: CardioGuidanceInstruction.MAINTAIN
private var candidate: CardioGuidanceInstruction? = null
private var candidateSinceMs: Long? = null
init {
require(hysteresisBpm in 0..20)
require(confirmationMs in 0L..30_000L)
}
fun reset() {
current = CardioGuidanceInstruction.MAINTAIN
candidate = null
candidateSinceMs = null
}
fun evaluate(
target: CardioTargetSnapshot?,
bpm: Int?,
fresh: Boolean,
nowMs: Long,
): CardioGuidanceOutput {
require(nowMs >= 0L)
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = target,
)
}
require(target.minimumBpm in 1..65535)
require(target.maximumBpm in target.minimumBpm..65535)
val desired = desiredInstruction(target, bpm)
if (desired == current) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(current, bpm, target)
}
if (candidate != desired) {
candidate = desired
candidateSinceMs = nowMs
}
val since = checkNotNull(candidateSinceMs)
if (nowMs - since >= confirmationMs) {
current = desired
candidate = null
candidateSinceMs = null
}
return CardioGuidanceOutput(
instruction = current,
bpm = bpm,
target = target,
pendingInstruction = candidate,
)
}
private fun desiredInstruction(
target: CardioTargetSnapshot,
bpm: Int,
): CardioGuidanceInstruction {
// A target entered from either side immediately trends toward MAINTAIN.
if (bpm in target.minimumBpm..target.maximumBpm) {
return CardioGuidanceInstruction.MAINTAIN
}
return when (current) {
CardioGuidanceInstruction.ACCELERATE ->
if (bpm >= target.minimumBpm + hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.ACCELERATE
}
CardioGuidanceInstruction.SLOW_DOWN ->
if (bpm <= target.maximumBpm - hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.SLOW_DOWN
}
CardioGuidanceInstruction.MAINTAIN,
CardioGuidanceInstruction.SUSPENDED ->
when {
bpm < target.minimumBpm - hysteresisBpm ->
CardioGuidanceInstruction.ACCELERATE
bpm > target.maximumBpm + hysteresisBpm ->
CardioGuidanceInstruction.SLOW_DOWN
else -> CardioGuidanceInstruction.MAINTAIN
}
}
}
companion object {
const val DEFAULT_HYSTERESIS_BPM = 2
const val DEFAULT_CONFIRMATION_MS = 3_000L
}
}

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import java.util.concurrent.CopyOnWriteArraySet
internal data class CardioGuidanceLiveSnapshot(
val phaseId: String? = null,
val phaseKind: CardioPhaseKind? = null,
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
val bpm: Int? = null,
val target: CardioTargetSnapshot? = null,
val startedAt: String? = null,
)
internal object CardioGuidanceLiveState {
private val listeners =
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = CardioGuidanceLiveSnapshot()
fun current(): CardioGuidanceLiveSnapshot = snapshot
fun publish(next: CardioGuidanceLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun clear() {
publish(CardioGuidanceLiveSnapshot())
}
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
}

View file

@ -195,7 +195,8 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
localRoot.mkdirs()
val coordination = listOf(
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
"android-generation-error-v1.json", "request-v1.json",
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
"request-v1.json",
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
)
@ -261,6 +262,13 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
upload("android-generation-v1.json", reference)
}
fun pushRecoveryAcknowledgements(localRoot: File) {
upload(
"android-archive-acknowledgements-v1.json",
File(localRoot, "android-archive-acknowledgements-v1.json"),
)
}
fun pushAcknowledgement(localRoot: File) {
upload(
"android-consumption-ack-v1.json",
@ -318,6 +326,10 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
local,
Duration.ofMinutes(10),
afterPeerPublication = { transport.pushPeer(local) },
afterRecoveryAcknowledgements = {
stage = "recovery-acknowledgement"
transport.pushRecoveryAcknowledgements(local)
},
afterPublication = {
stage = "generation-publication"
transport.pushGeneration(local)

View file

@ -0,0 +1,482 @@
/*
* Android-only BLE Heart Rate diagnostics.
*
* This owner is intentionally bounded and screen-scoped. Long-running capture
* belongs to the later foreground acquisition service, not this diagnostic.
*/
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import androidx.core.content.ContextCompat
import java.util.UUID
internal val HEART_RATE_SERVICE_UUID: UUID =
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
internal val BATTERY_SERVICE_UUID: UUID =
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
internal enum class HeartRateDiagnosticPhase {
IDLE,
SCANNING,
CONNECTING,
DISCOVERING,
SUBSCRIBING,
RECEIVING,
DISCONNECTED,
ERROR,
}
internal data class HeartRateDiagnosticDevice(
val address: String,
val name: String?,
val rssi: Int,
val advertisesHeartRate: Boolean,
)
internal data class HeartRateDiagnosticSnapshot(
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
val selectedAddress: String? = null,
val serviceFound: Boolean = false,
val measurementFound: Boolean = false,
val batteryServiceFound: Boolean = false,
val notificationCount: Int = 0,
val latestMeasurement: ParsedHeartRateMeasurement? = null,
val diagnostic: String? = null,
)
internal fun heartRateBleRuntimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
heartRateBleRuntimePermissions().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
internal class HeartRateBleDiagnostics(
context: Context,
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
) : AutoCloseable {
private val appContext = context.applicationContext
private val main = Handler(Looper.getMainLooper())
private val bluetoothManager =
appContext.getSystemService(BluetoothManager::class.java)
private val adapter get() = bluetoothManager?.adapter
private val devices = linkedMapOf<String, BluetoothDevice>()
private var snapshot = HeartRateDiagnosticSnapshot()
private var scannerCallback: ScanCallback? = null
private var scanStop: Runnable? = null
private var gatt: BluetoothGatt? = null
private var closed = false
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
if (closed) return
if (Looper.myLooper() != Looper.getMainLooper()) {
main.post { update(transform) }
return
}
snapshot = transform(snapshot)
onSnapshot(snapshot)
}
@SuppressLint("MissingPermission")
fun startScan(includeAllBle: Boolean = false) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val currentAdapter = adapter
if (currentAdapter == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
return
}
if (!currentAdapter.isEnabled) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
return
}
val scanner = currentAdapter.bluetoothLeScanner
if (scanner == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
return
}
stopScan()
devices.clear()
update {
HeartRateDiagnosticSnapshot(
phase = HeartRateDiagnosticPhase.SCANNING,
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
)
}
val callback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach(::handleScanResult)
}
override fun onScanFailed(errorCode: Int) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "scan_failed:" + errorCode,
)
}
}
}
scannerCallback = callback
val filters =
if (includeAllBle) {
emptyList()
} else {
listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
.build(),
)
}
scanner.startScan(
filters,
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
callback,
)
val stopper =
Runnable {
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
stopScan()
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
}
}
scanStop = stopper
main.postDelayed(stopper, SCAN_DURATION_MS)
}
@SuppressLint("MissingPermission")
private fun handleScanResult(result: ScanResult) {
val record = result.scanRecord
val advertised =
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
val device = result.device
devices[device.address] = device
val row =
HeartRateDiagnosticDevice(
address = device.address,
name = record?.deviceName,
rssi = result.rssi,
advertisesHeartRate = advertised,
)
update { old ->
val rows =
(old.devices.filterNot { it.address == row.address } + row)
.sortedWith(
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
.thenByDescending { it.rssi },
)
old.copy(devices = rows)
}
}
@SuppressLint("MissingPermission")
fun connect(address: String) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val device =
devices[address]
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
?: run {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
return
}
stopScan()
closeGatt()
update {
it.copy(
phase = HeartRateDiagnosticPhase.CONNECTING,
selectedAddress = address,
serviceFound = false,
measurementFound = false,
batteryServiceFound = false,
notificationCount = 0,
latestMeasurement = null,
diagnostic = "connecting",
)
}
@Suppress("DEPRECATION")
runCatching {
device.connectGatt(
appContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess { gatt = it }
.onFailure { error ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "connect_failed:" + error.javaClass.simpleName,
)
}
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCOVERING,
diagnostic = "connected",
)
}
if (!callbackGatt.discoverServices()) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "discover_start_failed",
)
}
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCONNECTED,
diagnostic = "disconnected:" + status,
)
}
closeGatt()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "gatt_status:" + status,
)
}
closeGatt()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "service_discovery:" + status,
)
}
return@post
}
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
update {
it.copy(
serviceFound = service != null,
measurementFound = measurement != null,
batteryServiceFound = battery,
)
}
if (measurement == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "heart_rate_measurement_missing",
)
}
return@post
}
subscribe(callbackGatt, measurement)
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
update {
if (status == BluetoothGatt.GATT_SUCCESS) {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
diagnostic = "notifications_enabled",
)
} else {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write:" + status,
)
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
handleMeasurement(characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
handleMeasurement(characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
) {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "notification_enable_failed",
)
}
return
}
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
if (descriptor == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "ccc_descriptor_missing",
)
}
return
}
update {
it.copy(
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
diagnostic = "enabling_notifications",
)
}
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
if (!started) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write_start_failed",
)
}
}
}
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
when (val parsed = HeartRateMeasurementParser.parse(value)) {
is HeartRateMeasurementParseResult.Invalid ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "measurement:" + parsed.reason,
)
}
is HeartRateMeasurementParseResult.Parsed ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
notificationCount = it.notificationCount + 1,
latestMeasurement = parsed.measurement,
diagnostic = "measurement",
)
}
}
}
@SuppressLint("MissingPermission")
fun stopScan() {
val callback = scannerCallback ?: return
scanStop?.let(main::removeCallbacks)
scanStop = null
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
scannerCallback = null
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun close() {
if (closed) return
stopScan()
closeGatt()
closed = true
}
private companion object {
const val SCAN_DURATION_MS = 10_000L
}
}

View file

@ -0,0 +1,80 @@
package com.labfytools.trainlog.data
import java.util.concurrent.CopyOnWriteArraySet
internal enum class HeartRateLivePhase {
DISCONNECTED,
CONNECTING,
CONNECTED,
STALE,
}
internal data class HeartRateLiveSnapshot(
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
val bpm: Int? = null,
val rrIntervals1024: List<Int> = emptyList(),
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val notificationCount: Long = 0,
)
internal object HeartRateLiveState {
private val listeners =
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = HeartRateLiveSnapshot()
fun current(): HeartRateLiveSnapshot = snapshot
fun publish(next: HeartRateLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
fun disconnected() {
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
}
fun connecting() {
publish(
snapshot.copy(
phase = HeartRateLivePhase.CONNECTING,
bpm = null,
rrIntervals1024 = emptyList(),
sensorContactDetected = null,
energyExpended = null,
),
)
}
fun measurement(measurement: ParsedHeartRateMeasurement) {
publish(
HeartRateLiveSnapshot(
phase = HeartRateLivePhase.CONNECTED,
bpm = measurement.bpm,
rrIntervals1024 = measurement.rrIntervals1024,
sensorContactDetected = measurement.sensorContactDetected,
energyExpended = measurement.energyExpended,
notificationCount = snapshot.notificationCount + 1,
),
)
}
fun stale() {
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
publish(
snapshot.copy(
phase = HeartRateLivePhase.STALE,
bpm = null,
),
)
}
}
}

View file

@ -0,0 +1,103 @@
/*
* Bluetooth Heart Rate Measurement parser.
*
* This file deliberately has no Android Bluetooth dependency so protocol
* decoding can be exhaustively unit-tested independently of GATT transport.
*/
package com.labfytools.trainlog.data
internal data class ParsedHeartRateMeasurement(
val bpm: Int,
val sensorContactDetected: Boolean?,
val energyExpended: Int?,
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
val rrIntervals1024: List<Int>,
)
internal sealed interface HeartRateMeasurementParseResult {
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
HeartRateMeasurementParseResult
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
}
internal object HeartRateMeasurementParser {
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
private const val FLAG_CONTACT_DETECTED = 1 shl 1
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
private const val FLAG_ENERGY_PRESENT = 1 shl 3
private const val FLAG_RR_PRESENT = 1 shl 4
private const val KNOWN_FLAGS =
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
val flags = payload[0].toInt() and 0xff
if ((flags and KNOWN_FLAGS.inv()) != 0) {
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
}
var offset = 1
fun uint8(): Int? =
payload.getOrNull(offset)?.let {
offset += 1
it.toInt() and 0xff
}
fun uint16(): Int? {
if (offset + 2 > payload.size) return null
val value =
(payload[offset].toInt() and 0xff) or
((payload[offset + 1].toInt() and 0xff) shl 8)
offset += 2
return value
}
val bpm =
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
} else {
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
}
val contact =
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
null
} else {
(flags and FLAG_CONTACT_DETECTED) != 0
}
val energy =
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
uint16()
?: return HeartRateMeasurementParseResult.Invalid(
"truncated energy-expended value",
)
} else {
null
}
val rr = mutableListOf<Int>()
if ((flags and FLAG_RR_PRESENT) != 0) {
if (offset == payload.size) {
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
}
if ((payload.size - offset) % 2 != 0) {
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
}
while (offset < payload.size) {
rr += checkNotNull(uint16())
}
} else if (offset != payload.size) {
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
}
return HeartRateMeasurementParseResult.Parsed(
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = contact,
energyExpended = energy,
rrIntervals1024 = rr,
),
)
}
}

View file

@ -0,0 +1,46 @@
package com.labfytools.trainlog.data
import android.content.Context
internal data class SelectedHeartRateSensor(
val address: String,
val name: String?,
)
internal class HeartRateSensorPreferences(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun selected(): SelectedHeartRateSensor? {
if (!preferences.getBoolean(ENABLED, false)) return null
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
return SelectedHeartRateSensor(
address = address,
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
)
}
fun select(address: String, name: String?) {
require(address.isNotBlank())
preferences.edit()
.putBoolean(ENABLED, true)
.putString(ADDRESS, address)
.putString(NAME, name?.takeIf { it.isNotBlank() })
.apply()
}
fun clear() {
preferences.edit()
.putBoolean(ENABLED, false)
.remove(ADDRESS)
.remove(NAME)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-heart-rate-sensor"
private const val ENABLED = "enabled"
private const val ADDRESS = "address"
private const val NAME = "name"
}
}

View file

@ -0,0 +1,561 @@
package com.labfytools.trainlog.data
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.R
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import java.time.Duration
import java.time.OffsetDateTime
class HeartRateSensorService : Service() {
private val main = Handler(Looper.getMainLooper())
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
private val bluetoothManager by lazy {
applicationContext.getSystemService(BluetoothManager::class.java)
}
private var selected: SelectedHeartRateSensor? = null
private var gatt: BluetoothGatt? = null
private var reconnectAttempt = 0
private var lastMeasurementElapsed = 0L
private var lastNotificationBpm: Int? = null
private var repository: TrainlogRepository? = null
private var guidancePhaseId: String? = null
private var guidanceEngine: CardioGuidanceEngine? = null
private fun repository(): TrainlogRepository =
repository ?: TrainlogRepository(applicationContext).also { repository = it }
private val reconnectRunnable = Runnable { connectSelected() }
private val connectionTimeoutRunnable =
Runnable {
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
closeGatt()
scheduleReconnect()
}
}
private val staleRunnable =
object : Runnable {
override fun run() {
val last = lastMeasurementElapsed
if (last > 0L) {
val age = SystemClock.elapsedRealtime() - last
if (
age >= STALE_AFTER_MS &&
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
) {
HeartRateLiveState.stale()
suspendCardioGuidance()
updateForegroundNotification()
}
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
lastMeasurementElapsed = 0L
closeGatt()
scheduleReconnect()
}
}
main.postDelayed(this, STALE_CHECK_MS)
}
}
override fun onCreate() {
super.onCreate()
createChannel()
promoteForeground()
main.post(staleRunnable)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
selected = preferences.selected()
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return START_NOT_STICKY
}
if (bluetoothManager.adapter?.isEnabled != true) {
scheduleReconnect()
return START_STICKY
}
promoteForeground()
if (gatt == null) connectSelected()
return START_STICKY
}
@SuppressLint("MissingPermission")
@Suppress("DEPRECATION")
private fun connectSelected() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
val sensor = preferences.selected()
selected = sensor
val adapter = bluetoothManager.adapter
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return
}
if (adapter?.isEnabled != true) {
scheduleReconnect()
return
}
closeGatt()
lastMeasurementElapsed = 0L
HeartRateLiveState.connecting()
updateForegroundNotification()
val device =
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
?: run {
HeartRateLiveState.disconnected()
scheduleReconnect()
return
}
runCatching {
device.connectGatt(
applicationContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess {
gatt = it
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
.onFailure {
scheduleReconnect()
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (callbackGatt !== gatt) return@post
if (
status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
reconnectAttempt = 0
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
if (!callbackGatt.discoverServices()) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
failConnection()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
return@post
}
val measurement =
callbackGatt
.getService(HEART_RATE_SERVICE_UUID)
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
if (measurement == null) {
failConnection()
return@post
}
main.removeCallbacks(connectionTimeoutRunnable)
if (!subscribe(callbackGatt, measurement)) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
} else {
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
): Boolean {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
}
private fun onHeartRateNotification(
callbackGatt: BluetoothGatt,
uuid: java.util.UUID,
value: ByteArray,
) {
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
val parsed = HeartRateMeasurementParser.parse(value)
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
lastMeasurementElapsed = SystemClock.elapsedRealtime()
HeartRateLiveState.measurement(parsed.measurement)
val observedAt = OffsetDateTime.now().toString()
val repository = repository()
val sessionRecorded =
repository.recordLiveHeartRateForActiveSession(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
if (sessionRecorded != null) {
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
} else {
repository.recordLiveHeartRateForActiveSleep(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
clearCardioGuidanceEngine()
}
if (lastNotificationBpm != parsed.measurement.bpm) {
lastNotificationBpm = parsed.measurement.bpm
updateForegroundNotification()
}
}
private fun processCardioGuidance(
repository: TrainlogRepository,
observedAt: String,
bpm: Int,
) {
val phase = repository.activeCardioGuidancePhase()
if (phase == null) {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val engine = checkNotNull(guidanceEngine)
val output =
engine.evaluate(
target = phase.phase.target,
bpm = bpm,
fresh = true,
nowMs = SystemClock.elapsedRealtime(),
)
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = output.instruction,
bpm = output.bpm,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
}
private fun suspendCardioGuidance() {
val repository = repository ?: return
val phase = repository.activeCardioGuidancePhase() ?: run {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val output =
checkNotNull(guidanceEngine).evaluate(
target = phase.phase.target,
bpm = null,
fresh = false,
nowMs = SystemClock.elapsedRealtime(),
)
val observedAt = OffsetDateTime.now().toString()
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
}
private fun maybeFinishCardioGuidancePhase(
repository: TrainlogRepository,
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
observedAt: String,
bpm: Int?,
fresh: Boolean,
) {
val elapsedSeconds =
runCatching {
Duration.between(
OffsetDateTime.parse(phase.startedAt),
OffsetDateTime.parse(observedAt),
).seconds
}
.getOrNull()
?.coerceIn(0L, Int.MAX_VALUE.toLong())
?.toInt()
?: return
if (
CardioPhaseCompletion.isComplete(
phase.phase,
elapsedSeconds,
bpm,
fresh,
)
) {
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
clearCardioGuidanceEngine()
}
}
private fun clearCardioGuidanceEngine() {
guidancePhaseId = null
guidanceEngine = null
CardioGuidanceLiveState.clear()
}
private fun failConnection() {
main.removeCallbacks(connectionTimeoutRunnable)
closeGatt()
lastMeasurementElapsed = 0L
lastNotificationBpm = null
scheduleReconnect()
}
private fun scheduleReconnect() {
if (preferences.selected() == null) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
return
}
HeartRateLiveState.connecting()
updateForegroundNotification()
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
val delay =
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
main.removeCallbacks(reconnectRunnable)
main.postDelayed(reconnectRunnable, delay)
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun onDestroy() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
main.removeCallbacks(staleRunnable)
closeGatt()
repository?.close()
repository = null
HeartRateLiveState.disconnected()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(
CHANNEL,
getString(R.string.heart_rate_service_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun promoteForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateForegroundNotification() {
runCatching {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification())
}
}
private fun buildNotification(): android.app.Notification {
val live = HeartRateLiveState.current()
val text =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
?: getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.CONNECTING ->
getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.STALE ->
getString(R.string.heart_rate_service_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
getString(R.string.heart_rate_service_disconnected)
}
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.heart_rate_service_title))
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
companion object {
private const val CHANNEL = "trainlog_heart_rate"
private const val NOTIFICATION_ID = 1402
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
private const val CONNECTION_TIMEOUT_MS = 12_000L
private const val STALE_AFTER_MS = 3_000L
private const val STALE_RECONNECT_AFTER_MS = 8_000L
private const val STALE_CHECK_MS = 1_000L
private const val RECONNECT_BASE_MS = 2_000L
private const val RECONNECT_MAX_MS = 15_000L
fun start(context: Context) {
if (
HeartRateSensorPreferences(context).selected() == null ||
!hasHeartRateBleRuntimePermissions(context)
) {
HeartRateLiveState.disconnected()
return
}
ContextCompat.startForegroundService(
context,
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
)
}
fun stop(context: Context) {
context.startService(
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
)
}
fun forget(context: Context) {
HeartRateSensorPreferences(context).clear()
stop(context)
HeartRateLiveState.disconnected()
}
}
}

View file

@ -45,6 +45,13 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
}
}
internal fun acknowledgementMatchesCapturedGeneration(
acknowledgement: JSONObject,
captured: CapturedSyncGeneration,
): Boolean =
acknowledgement.optString("generation_id") == captured.generationId &&
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository,
@ -54,6 +61,11 @@ internal class SyncGenerationCoordinator(
) {
private val service = SyncGenerationService(repository)
private companion object {
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
const val MAX_COORDINATION_BYTES = 65_536
}
private fun phase(
runId: String?,
phase: String,
@ -64,15 +76,9 @@ internal class SyncGenerationCoordinator(
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
sql,
arguments,
).use { cursor ->
if (!cursor.moveToFirst()) null
else {
@ -88,6 +94,29 @@ internal class SyncGenerationCoordinator(
captured
}
}
query(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
) ?: if (consumerPeerId == null) null else query(
/* WHY: an RFCOMM reset creates a new conversation run while
* the immutable generation still awaits its correlated ACK.
* CONTRACT: resume only the unsuperseded generation for the
* same peer; the reference adopts the new run while manifest
* and generation identity remain unchanged. */
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
"ORDER BY g.generated_at DESC LIMIT 2",
arrayOf(consumerPeerId),
)
}
private fun request(directory: File): JSONObject? {
@ -196,6 +225,45 @@ internal class SyncGenerationCoordinator(
if (actual != expected) throw SyncGenerationException("invalid $label fields")
}
private fun requestSupports(request: JSONObject, capability: String): Boolean {
val capabilities = request.optJSONArray("capabilities") ?: return false
for (index in 0 until capabilities.length()) {
if (capabilities.optString(index) == capability) return true
}
return false
}
private fun recoveryAcknowledgements(
desktopPeerId: String,
androidPeerId: String,
): JSONArray =
repository.inSyncGenerationTransaction { db ->
val acknowledgements = JSONArray()
db.rawQuery(
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? " +
"AND consumer_peer_id=? AND result IN('consumed','rejected') " +
"ORDER BY consumed_at DESC,generation_id DESC LIMIT 32",
arrayOf(desktopPeerId, androidPeerId),
)
.use { cursor ->
while (cursor.moveToNext()) {
val acknowledgement = JSONObject(cursor.getString(0))
/* ACKs written before cross-runtime rejection diagnostics
* were normalized can hash '/' differently in Android
* and Python JSON canonicalizers. They remain immutable
* evidence, but must not block recovery of newer,
* compatible ACKs. Consumed ACKs are unaffected. */
if (
acknowledgement.optString("result") != "rejected" ||
!acknowledgement.optString("diagnostic").contains("/")
) {
acknowledgements.put(acknowledgement)
}
}
}
acknowledgements
}
private fun reconcileArchiveAcknowledgements(
raw: ByteArray,
runId: String,
@ -299,6 +367,7 @@ internal class SyncGenerationCoordinator(
"mobile-history-v4",
"execution-draft-v1",
"generation-archive-v1",
ACK_RECOVERY_CAPABILITY,
"session-preparations-v2",
"programs-v1",
)
@ -359,6 +428,7 @@ internal class SyncGenerationCoordinator(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterRecoveryAcknowledgements: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null,
@ -433,6 +503,44 @@ internal class SyncGenerationCoordinator(
request.getString("desktop_peer_id"),
)
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
if (requestSupports(request, ACK_RECOVERY_CAPABILITY)) {
/* WHY: Android may commit consumption after the desktop
* producer has already published its immutable generation, then
* transport can fail before the ACK reaches that producer.
* CONTRACT: return only exact durable consumer ACK evidence,
* bounded to the newest 32 rows, before either side captures a
* new generation. INVARIANT: recovery never fabricates lineage
* and never rewrites Android consumption history. */
val recoveryPath =
File(directory, "android-archive-acknowledgements-v1.json")
val recoveryEnvelope =
JSONObject()
.put("format", "trainlog-sync-archive-acknowledgements")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", peer)
.put("desktop_peer_id", request.getString("desktop_peer_id"))
.put(
"acknowledgements",
recoveryAcknowledgements(
request.getString("desktop_peer_id"),
peer,
),
)
.toString()
if (recoveryEnvelope.toByteArray(Charsets.UTF_8).size > MAX_COORDINATION_BYTES)
throw SyncGenerationException(
"Android archive acknowledgement envelope exceeds bound"
)
publish(recoveryPath, recoveryEnvelope)
visibility.confirm(listOf(recoveryPath))
afterRecoveryAcknowledgements?.invoke()
phase(
runId,
"android_archive_ack_published",
recoveryPath.name,
)
}
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
@ -486,12 +594,15 @@ internal class SyncGenerationCoordinator(
afterPublication?.invoke()
lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored.
// the exchange directory. A cross-run retry preserves the
// immutable manifest's original run_id, so correlate its ACK by
// generation identity and manifest digest. acceptAcknowledgement
// then revalidates the complete durable ACK against SQLite.
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
// ignored; an exact replay is deliberately accepted.
val desktopAck =
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
it.optString("run_id") == captured.runId &&
it.optString("generation_id") == captured.generationId
acknowledgementMatchesCapturedGeneration(it, captured)
}, pollTransport, lease)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)

View file

@ -52,6 +52,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
linkedMapOf(
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
"history" to Kind("trainlog-mobile-export", 4, "history-v4.json"),
"cardio-sessions" to
Kind("trainlog-cardio-sessions", 1, "cardio-sessions-v1.json", false),
"execution-drafts" to
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
"exercise-aliases" to
@ -68,7 +70,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
"program-executions" to
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 2, "sleep-diary-v2.json", false),
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
"heart-rate-corrections" to
Kind("trainlog-heart-rate-corrections", 1,
"heart-rate-corrections-v1.json", false),
"cardio-calibrations" to
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
"cardio-guidance" to
Kind("trainlog-cardio-guidance", 1, "cardio-guidance-v1.json", false),
"session-timeline" to
Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false),
)
private val SUPPORTED =
(ANDROID_KINDS.values +
@ -285,7 +297,26 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
repository.inSyncGenerationTransaction { db ->
val retained =
db.rawQuery(
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
/* WHY: early deployed generation builds could advance a
* producer row to acknowledged without retaining the ACK
* ledger row. Such terminal rows cannot be archived
* safely because Trainlog must not fabricate evidence,
* but they must not exhaust the active admission window
* forever. A failed conversation can also leave an
* unacknowledged branch whose sibling later receives a
* durable ACK; that superseded branch remains retained
* for audit but can never be the causal tip. INVARIANT:
* only a ledger-backed acknowledged sibling proves that
* an unacknowledged branch no longer consumes admission. */
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
"((status IN('captured','published','waiting_acknowledgement') AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements sibling_ack " +
"ON sibling_ack.generation_id=sibling.generation_id WHERE " +
"sibling.parent_generation_id=g.parent_generation_id AND " +
"sibling.generation_id<>g.generation_id AND sibling.status='acknowledged')) OR " +
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
arrayOf(consumerPeerId),
)
.use {
@ -331,10 +362,35 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
)
}
val generatedAt = OffsetDateTime.now().toString()
/* WHY: a fully acknowledged fork can remain in the immutable
* ledger after its payload has been durably archived.
* CONTRACT: archived evidence stays queryable but no longer
* participates in the active producer-lineage tip selection.
* INVARIANT: only unarchived rows backed by a full durable ACK can
* become the parent; two such active tips still reject capture. */
val parent =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2",
"SELECT g.generation_id FROM sync_generations g WHERE " +
"g.producer_peer_id=? AND g.consumer_peer_id=? AND " +
"g.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
"sync_acknowledgements k WHERE " +
"k.generation_id=g.generation_id AND " +
"k.result='consumed' AND " +
"k.durability='sqlite-commit-full') AND " +
"NOT EXISTS(SELECT 1 FROM " +
"sync_generation_archives a WHERE " +
"a.generation_id=g.generation_id) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations c WHERE " +
"c.parent_generation_id=g.generation_id AND " +
"c.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
"sync_acknowledgements ck WHERE " +
"ck.generation_id=c.generation_id AND " +
"ck.result='consumed' AND " +
"ck.durability='sqlite-commit-full') AND " +
"NOT EXISTS(SELECT 1 FROM " +
"sync_generation_archives ca WHERE " +
"ca.generation_id=c.generation_id)) LIMIT 2",
arrayOf(producer, consumerPeerId),
)
.use {
@ -412,6 +468,24 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val heartRateCorrections =
JSONObject(checkNotNull(artifacts["heart-rate-corrections"]))
.getJSONArray("corrections")
for (index in 0 until heartRateCorrections.length()) {
val operationId =
heartRateCorrections.getJSONObject(index).getString("operation_id")
val existed =
db.rawQuery(
"SELECT 1 FROM sync_causal_publications " +
"WHERE operation_id=? LIMIT 1",
arrayOf(operationId),
)
.use { it.moveToFirst() }
db.execSQL(
"INSERT INTO sync_causal_publications VALUES(?,?,?)",
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val sleep = JSONObject(checkNotNull(artifacts["sleep-diary"]))
val sleepEntries = sleep.getJSONArray("entries")
for (index in 0 until sleepEntries.length()) {
@ -422,6 +496,48 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")),
)
}
val heartRate = JSONObject(checkNotNull(artifacts["heart-rate"]))
val captures = heartRate.getJSONArray("captures")
for (index in 0 until captures.length()) {
db.execSQL(
"INSERT INTO heart_rate_generation_captures(generation_id,capture_id) VALUES(?,?)",
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
)
}
val calibrations = JSONObject(checkNotNull(artifacts["cardio-calibrations"]))
val calibrationItems = calibrations.getJSONArray("calibrations")
for (index in 0 until calibrationItems.length()) {
db.execSQL(
"INSERT INTO cardio_calibration_generation(generation_id,calibration_id) VALUES(?,?)",
arrayOf(
generationId,
calibrationItems.getJSONObject(index).getString("calibration_id"),
),
)
}
val guidance = JSONObject(checkNotNull(artifacts["cardio-guidance"]))
val guidanceRuns = guidance.getJSONArray("runs")
for (index in 0 until guidanceRuns.length()) {
db.execSQL(
"INSERT INTO cardio_guidance_generation(generation_id,run_id) VALUES(?,?)",
arrayOf(
generationId,
guidanceRuns.getJSONObject(index).getString("run_id"),
),
)
}
val timeline = JSONObject(checkNotNull(artifacts["session-timeline"]))
val timelineSessions = timeline.getJSONArray("sessions")
for (index in 0 until timelineSessions.length()) {
db.execSQL(
"INSERT INTO session_timeline_generation_sessions(generation_id,session_id) " +
"VALUES(?,?)",
arrayOf(
generationId,
timelineSessions.getJSONObject(index).getString("session_id"),
),
)
}
}
return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) {
@ -783,26 +899,38 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
}
}
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
val expectedParent =
/* WHY: the producer can archive one transient fork while this
* consumer retains both immutable consumed audit rows.
* CONTRACT: a new generation extends any current consumed tip;
* a null, old, or unrelated parent remains invalid.
* INVARIANT: fork history is never rewritten to select a tip. */
val tipPredicate =
"g.producer_peer_id=? AND g.result='consumed' AND " +
"NOT EXISTS(SELECT 1 FROM sync_consumed_generations c " +
"WHERE c.parent_generation_id=g.generation_id AND " +
"c.result='consumed')"
val hasCurrentTip =
db.rawQuery(
"SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",
"SELECT 1 FROM sync_consumed_generations g WHERE " +
tipPredicate + " LIMIT 1",
arrayOf(producerPeer),
)
.use {
if (!it.moveToFirst()) null
else {
val value = it.getString(0)
if (it.moveToNext())
throw SyncGenerationException(
"consumed generation lineage has multiple tips"
)
value
}
}
.use { it.moveToFirst() }
val suppliedParent =
if (manifest.isNull("parent_generation_id")) null
else manifest.getString("parent_generation_id")
if (suppliedParent != expectedParent)
val suppliedParentIsTip =
suppliedParent != null &&
db.rawQuery(
"SELECT 1 FROM sync_consumed_generations g WHERE " +
"g.generation_id=? AND " + tipPredicate + " LIMIT 1",
arrayOf(suppliedParent, producerPeer),
)
.use { it.moveToFirst() }
if (
(!hasCurrentTip && suppliedParent != null) ||
(hasCurrentTip && !suppliedParentIsTip)
)
throw SyncGenerationException("generation lineage is stale or unrelated")
val a = frozen.artifacts
fun apply(name: String, result: () -> Any) {
@ -1059,6 +1187,38 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
ack.getString("generation_id")),
)
db.execSQL(
"UPDATE heart_rate_captures SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM heart_rate_generation_captures m " +
"WHERE m.generation_id=? AND " +
"m.capture_id=heart_rate_captures.capture_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
db.execSQL(
"UPDATE cardio_calibrations SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM cardio_calibration_generation m " +
"WHERE m.generation_id=? AND " +
"m.calibration_id=cardio_calibrations.calibration_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
db.execSQL(
"UPDATE cardio_guidance_runs SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM cardio_guidance_generation m " +
"WHERE m.generation_id=? AND " +
"m.run_id=cardio_guidance_runs.run_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
db.execSQL(
"UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM session_timeline_generation_sessions m " +
"WHERE m.generation_id=? AND " +
"m.session_id=session_timeline_sessions.session_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
}
target
}

View file

@ -0,0 +1,37 @@
package com.labfytools.trainlog.model
enum class CardioCalibrationPhase(val wireValue: String) {
WARMUP("warmup"),
PROGRESSIVE("progressive"),
HIGH_EFFORT("high_effort"),
RECOVERY("recovery"),
COMPLETED("completed"),
ABORTED("aborted");
companion object {
fun fromWire(value: String): CardioCalibrationPhase =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown cardio calibration phase: $value")
}
}
data class CardioCalibrationRecoveryPoint(
val targetOffsetSeconds: Int,
val observedAt: String,
val bpm: Int,
)
data class CardioCalibrationProfile(
val calibrationId: String,
val protocolVersion: Int,
val sessionId: String,
val entryId: String,
val phase: CardioCalibrationPhase,
val startedAt: String,
val phaseStartedAt: String,
val effortEndAt: String?,
val endedAt: String?,
val heartRateCaptureId: String?,
val observedPeakBpm: Int?,
val recovery: List<CardioCalibrationRecoveryPoint>,
)

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.model
enum class CardioPhaseKind(val wireValue: String) {
WARMUP("warmup"),
WORK("work"),
RECOVERY("recovery"),
COOLDOWN("cooldown"),
}
sealed interface CardioTargetDefinition {
data class AbsoluteBpm(
val minimumBpm: Int,
val maximumBpm: Int,
) : CardioTargetDefinition
data class CalibrationPercent(
val calibrationId: String,
val observedPeakBpm: Int,
val minimumPercent: Int,
val maximumPercent: Int,
) : CardioTargetDefinition
}
/**
* Immutable target actually used by one phase.
*
* The calibration identity and percentages are provenance only. Runtime
* guidance consumes the resolved BPM bounds so later calibration changes
* cannot rewrite an already-started session.
*/
data class CardioTargetSnapshot(
val minimumBpm: Int,
val maximumBpm: Int,
val calibrationId: String? = null,
val calibrationObservedPeakBpm: Int? = null,
val minimumPercent: Int? = null,
val maximumPercent: Int? = null,
)
sealed interface CardioPhaseExitCondition {
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
data object EnterTarget : CardioPhaseExitCondition
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
data class DurationOrRecoverBelow(
val maximumSeconds: Int,
val bpm: Int,
) : CardioPhaseExitCondition
}
data class CardioGuidedPhase(
val phaseId: String,
val kind: CardioPhaseKind,
val target: CardioTargetSnapshot?,
val exitCondition: CardioPhaseExitCondition,
)
enum class CardioGuidanceInstruction {
ACCELERATE,
MAINTAIN,
SLOW_DOWN,
SUSPENDED,
}
data class CardioGuidanceOutput(
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val target: CardioTargetSnapshot?,
val pendingInstruction: CardioGuidanceInstruction? = null,
)
data class CardioGuidancePhaseRecord(
val runId: String,
val sessionId: String,
val entryId: String,
val phase: CardioGuidedPhase,
val position: Int,
val startedAt: String,
val endedAt: String?,
val currentInstruction: CardioGuidanceInstruction,
)
data class CardioGuidanceEvent(
val sequence: Long,
val observedAt: String,
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val targetMinimumBpm: Int?,
val targetMaximumBpm: Int?,
)
data class CardioGuidanceRun(
val runId: String,
val sessionId: String,
val startedAt: String,
val endedAt: String?,
val phases: List<CardioGuidancePhaseRecord>,
)

View file

@ -0,0 +1,46 @@
/*
* Android HeartRateModels model.
*
* Android owns sensor acquisition. Desktop/Web only consume synchronized
* measured facts and never connect to the heart-rate sensor.
*/
package com.labfytools.trainlog.model
enum class HeartRateContextKind(val wireValue: String) {
SESSION("session"),
CARDIO("cardio"),
SLEEP("sleep");
companion object {
fun fromWire(value: String): HeartRateContextKind =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown heart-rate context: $value")
}
}
data class HeartRateRrInterval(
val index: Int,
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
val value1024: Int,
)
data class HeartRateSample(
val sequence: Long,
val observedAt: String,
val bpm: Int,
val exerciseEntryId: String? = null,
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
)
data class HeartRateCapture(
val captureId: String,
val contextKind: HeartRateContextKind,
val contextId: String,
val startedAt: String,
val endedAt: String?,
val activeExerciseEntryId: String?,
val sensorName: String?,
val samples: List<HeartRateSample> = emptyList(),
)

View file

@ -9,17 +9,14 @@ enum class SessionType(
val wireValue: String,
) {
TRAINING("training"),
MAX_TEST("max_test");
MAX_TEST("max_test"),
CARDIO("cardio");
companion object {
fun fromWire(
value: String,
): SessionType =
if (value == "max_test") {
MAX_TEST
} else {
TRAINING
}
entries.firstOrNull { it.wireValue == value } ?: TRAINING
}
}
@ -79,6 +76,24 @@ data class SessionDraft(
val sessionType: SessionType = SessionType.TRAINING,
)
data class SessionExerciseTiming(
val sessionId: String,
val entryId: String,
val exerciseId: String,
val startedAt: String,
val endedAt: String?,
) {
val active: Boolean get() = endedAt == null
}
data class ActiveSessionTimelineContext(
val sessionId: String,
val startedAt: String,
val sessionType: SessionType,
val activeEntryId: String?,
)
data class SessionDraftForm(
val selectedExercise: ExerciseProfile? = null,
/** Null means creation; otherwise replace this durable draft entry. */

View file

@ -16,7 +16,7 @@ data class SleepMedication(
data class MedicationIntake(
val intakeId: String, val medicationId: String, val medicationName: String,
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
val createdAt: String,
val createdAt: String, val quantity: Int = 1,
)
data class SleepDiaryEntry(
val entryId: String, val nightStartDate: String, val nightEndDate: String,

View file

@ -69,6 +69,7 @@ fun AndroidAppShell(
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
AppRoute.CardioCalibration -> R.string.route_cardio_calibration
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
@ -77,6 +78,7 @@ fun AndroidAppShell(
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.SleepDiary -> R.string.nav_sleep
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
})
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
@ -145,6 +147,7 @@ fun AndroidAppShell(
}
},
actions = {
HeartRateIndicator()
if (!root) {
IconButton(
onClick = { scope.launch { drawerState.open() } },

View file

@ -24,6 +24,7 @@ sealed interface AppRoute {
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
data object CardioCalibration : AppRoute { override val section = AppSection.SESSIONS }
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
@ -39,6 +40,7 @@ sealed interface AppRoute {
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
}
fun AppSection.rootRoute(): AppRoute = when (this) {

View file

@ -0,0 +1,172 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun CardioCalibrationScreen(
repository: TrainlogRepository,
onChanged: () -> Unit,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember { mutableIntStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
val active = remember(revision) { repository.activeCardioCalibration() }
val latest = remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
message =
when (result) {
is TrainlogRepository.CardioCalibrationResult.Applied -> {
revision++
onChanged()
null
}
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
TrainlogRepository.CardioCalibrationResult.Conflict ->
strings.getString(R.string.calibration_conflict)
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
result.message
}
}
fun phaseLabel(phase: CardioCalibrationPhase): String =
strings.getString(
when (phase) {
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
},
)
TrainlogScreen(
strings.getString(R.string.route_cardio_calibration),
scrollKey = "cardio-calibration",
) {
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
if (active == null) {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_start),
strings.getString(R.string.calibration_start_description),
) {
apply(repository.startCardioCalibration())
}
latest?.let { profile ->
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
TrainlogInfo(
strings.getString(
R.string.calibration_peak_value,
profile.observedPeakBpm ?: 0,
),
colors.accent,
)
profile.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
}
} else {
TrainlogFrame(strings.getString(R.string.calibration_current)) {
TrainlogInfo(
strings.getString(
R.string.calibration_phase_value,
phaseLabel(active.phase),
),
colors.accent,
)
active.observedPeakBpm?.let { bpm ->
TrainlogInfo(
strings.getString(R.string.calibration_peak_value, bpm),
colors.warning,
)
}
active.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
when (active.phase) {
CardioCalibrationPhase.WARMUP -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_progressive),
strings.getString(R.string.calibration_progressive_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.PROGRESSIVE -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_high_effort),
strings.getString(R.string.calibration_high_effort_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.HIGH_EFFORT -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_end_effort),
strings.getString(R.string.calibration_end_effort_description),
) {
apply(repository.markCardioCalibrationEffortEnd())
}
}
CardioCalibrationPhase.RECOVERY -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_finish),
strings.getString(R.string.calibration_finish_description),
) {
apply(repository.finishCardioCalibration())
}
}
CardioCalibrationPhase.COMPLETED,
CardioCalibrationPhase.ABORTED -> Unit
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogButton(
strings.getString(R.string.calibration_abort),
{ apply(repository.abortCardioCalibration()) },
style = TrainlogButtonStyle.DESTRUCTIVE,
)
}
}
message?.let { TrainlogInfo(it, colors.error) }
}
}

View file

@ -0,0 +1,385 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.CardioGuidanceLiveState
import com.labfytools.trainlog.data.CardioTargetResolver
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import java.util.UUID
@Composable
internal fun CardioGuidancePanel(
repository: TrainlogRepository,
entryId: String,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember(entryId) { mutableStateOf(0) }
val active =
remember(entryId, revision) {
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
}
val calibration =
remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
DisposableEffect(entryId) {
val subscription = CardioGuidanceLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
TrainlogFrame(
title = strings.getString(R.string.cardio_guidance_title),
active = active != null,
) {
if (active != null) {
val phase = active.phase
val current = live.takeIf { it.phaseId == phase.phaseId }
val instruction = current?.instruction ?: active.currentInstruction
val bpm = current?.bpm
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_phase_value,
phaseKindLabel(strings, phase.kind),
),
colors.muted,
)
phase.target?.let { target ->
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_target_value,
target.minimumBpm,
target.maximumBpm,
),
colors.accent,
)
if (target.calibrationId != null) {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_calibration_value,
target.calibrationObservedPeakBpm ?: 0,
target.minimumPercent ?: 0,
target.maximumPercent ?: 0,
),
colors.muted,
)
}
}
TrainlogInfo(
guidanceInstructionLabel(strings, instruction),
when (instruction) {
CardioGuidanceInstruction.MAINTAIN -> colors.success
CardioGuidanceInstruction.ACCELERATE -> colors.accent
CardioGuidanceInstruction.SLOW_DOWN,
CardioGuidanceInstruction.SUSPENDED -> colors.warning
},
)
TrainlogInfo(
if (bpm == null) {
strings.getString(R.string.cardio_guidance_bpm_missing)
} else {
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
},
if (bpm == null) colors.warning else colors.text,
)
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_finish_phase),
onClick = {
if (
repository.finishCardioGuidancePhase(phase.phaseId) is
TrainlogRepository.CardioGuidanceMutationResult.Applied
) {
CardioGuidanceLiveState.clear()
revision++
}
},
modifier = Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SECONDARY,
)
return@TrainlogFrame
}
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
var targetMode by
remember(entryId, calibration?.calibrationId) {
mutableStateOf(if (calibration == null) "bpm" else "calibration")
}
var minimumBpm by remember(entryId) { mutableStateOf("") }
var maximumBpm by remember(entryId) { mutableStateOf("") }
var minimumPercent by remember(entryId) { mutableStateOf("") }
var maximumPercent by remember(entryId) { mutableStateOf("") }
var exitMode by remember(entryId) { mutableStateOf("duration") }
var durationSeconds by remember(entryId) { mutableStateOf("") }
var recoveryBpm by remember(entryId) { mutableStateOf("") }
var message by remember(entryId) { mutableStateOf<String?>(null) }
TrainlogChoiceChips(
choices =
listOf(
"warmup" to strings.getString(R.string.cardio_phase_warmup),
"work" to strings.getString(R.string.cardio_phase_work),
"recovery" to strings.getString(R.string.cardio_phase_recovery),
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
),
selectedId = kind.wireValue,
onSelected = { selected ->
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
},
)
TrainlogChoiceChips(
choices =
buildList {
add("bpm" to strings.getString(R.string.cardio_target_absolute))
if (calibration?.observedPeakBpm != null) {
add(
"calibration" to
strings.getString(R.string.cardio_target_calibration),
)
}
},
selectedId = targetMode,
onSelected = { targetMode = it },
)
if (targetMode == "calibration") {
calibration?.observedPeakBpm?.let { peak ->
TrainlogInfo(
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
colors.muted,
)
}
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_percent),
left = minimumPercent,
onLeft = { minimumPercent = digits(it, 3) },
rightLabel = strings.getString(R.string.cardio_target_max_percent),
right = maximumPercent,
onRight = { maximumPercent = digits(it, 3) },
suffix = "%",
)
} else {
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
left = minimumBpm,
onLeft = { minimumBpm = digits(it, 5) },
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
right = maximumBpm,
onRight = { maximumBpm = digits(it, 5) },
suffix = "BPM",
)
}
TrainlogChoiceChips(
choices =
listOf(
"duration" to strings.getString(R.string.cardio_exit_duration),
"target" to strings.getString(R.string.cardio_exit_target),
"recover" to strings.getString(R.string.cardio_exit_recover),
"bounded" to strings.getString(R.string.cardio_exit_bounded),
),
selectedId = exitMode,
onSelected = { exitMode = it },
)
if (exitMode == "duration" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_seconds),
value = durationSeconds,
onValueChange = { durationSeconds = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "s",
compact = true,
)
}
if (exitMode == "recover" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_recovery_bpm),
value = recoveryBpm,
onValueChange = { recoveryBpm = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "BPM",
compact = true,
)
}
val target =
if (targetMode == "calibration") {
val peak = calibration?.observedPeakBpm
val min = minimumPercent.toIntOrNull()
val max = maximumPercent.toIntOrNull()
if (calibration == null || peak == null || min == null || max == null) {
null
} else {
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibration.calibrationId,
peak,
min,
max,
),
)
}
} else {
val min = minimumBpm.toIntOrNull()
val max = maximumBpm.toIntOrNull()
if (min == null || max == null) null
else CardioTargetResolver.resolve(
CardioTargetDefinition.AbsoluteBpm(min, max),
)
}
target?.let {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_resolved_target,
it.minimumBpm,
it.maximumBpm,
),
colors.accent,
)
}
val exit =
when (exitMode) {
"duration" ->
durationSeconds.toIntOrNull()
?.takeIf { it in 1..86400 }
?.let(CardioPhaseExitCondition::FixedDuration)
"target" -> CardioPhaseExitCondition.EnterTarget
"recover" ->
recoveryBpm.toIntOrNull()
?.takeIf { it in 1..65535 }
?.let(CardioPhaseExitCondition::RecoverBelow)
"bounded" -> {
val seconds = durationSeconds.toIntOrNull()
val bpm = recoveryBpm.toIntOrNull()
if (seconds != null && bpm != null &&
seconds in 1..86400 && bpm in 1..65535
) {
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
} else null
}
else -> null
}
message?.let { TrainlogInfo(it, colors.warning) }
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_start_phase),
onClick = {
val actualTarget = target
val actualExit = exit
if (actualTarget == null || actualExit == null) {
message =
strings.getString(R.string.cardio_guidance_invalid_configuration)
return@TrainlogButton
}
val result =
repository.startCardioGuidancePhase(
entryId,
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = kind,
target = actualTarget,
exitCondition = actualExit,
),
)
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
message = null
revision++
} else {
message = strings.getString(R.string.cardio_guidance_start_failed)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = target != null && exit != null,
style = TrainlogButtonStyle.SUCCESS,
)
}
}
@Composable
private fun NumberPair(
leftLabel: String,
left: String,
onLeft: (String) -> Unit,
rightLabel: String,
right: String,
onRight: (String) -> Unit,
suffix: String,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogInputField(
label = leftLabel,
value = left,
onValueChange = onLeft,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
TrainlogInputField(
label = rightLabel,
value = right,
onValueChange = onRight,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
}
}
private fun phaseKindLabel(
strings: android.content.Context,
kind: CardioPhaseKind,
): String =
strings.getString(
when (kind) {
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
CardioPhaseKind.WORK -> R.string.cardio_phase_work
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
},
)
private fun guidanceInstructionLabel(
strings: android.content.Context,
instruction: CardioGuidanceInstruction,
): String =
strings.getString(
when (instruction) {
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
},
)
private fun digits(value: String, maximum: Int): String =
value.filter(Char::isDigit).take(maximum)

View file

@ -0,0 +1,203 @@
package com.labfytools.trainlog.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
val context = LocalContext.current
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
var pendingExpandedScan by remember { mutableStateOf(false) }
var permissionDenied by remember { mutableStateOf(false) }
val diagnostics =
remember(context) {
HeartRateBleDiagnostics(context) { next -> snapshot = next }
}
DisposableEffect(diagnostics) {
onDispose { diagnostics.close() }
}
val permissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
permissionDenied = false
diagnostics.startScan(pendingExpandedScan)
} else {
permissionDenied = true
}
}
fun scan(expanded: Boolean) {
pendingExpandedScan = expanded
if (hasHeartRateBleRuntimePermissions(context)) {
permissionDenied = false
diagnostics.startScan(expanded)
} else {
permissionLauncher.launch(heartRateBleRuntimePermissions())
}
}
fun phaseLabel(): String =
strings.getString(
when (snapshot.phase) {
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
},
)
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
TrainlogInfo(
phaseLabel(),
when (snapshot.phase) {
HeartRateDiagnosticPhase.RECEIVING -> colors.success
HeartRateDiagnosticPhase.ERROR -> colors.error
HeartRateDiagnosticPhase.CONNECTING,
HeartRateDiagnosticPhase.DISCOVERING,
HeartRateDiagnosticPhase.SUBSCRIBING,
HeartRateDiagnosticPhase.SCANNING -> colors.warning
else -> colors.muted
},
)
if (permissionDenied) {
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
}
snapshot.diagnostic?.let {
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
}
TrainlogInfo(
strings.getString(
R.string.hr_diag_services,
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
),
)
snapshot.latestMeasurement?.let { measurement ->
TrainlogInfo(
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
colors.success,
)
TrainlogInfo(
strings.getString(
R.string.hr_diag_measurement_meta,
measurement.rrIntervals1024.size,
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
measurement.energyExpended?.toString() ?: "—",
),
colors.muted,
)
}
TrainlogInfo(
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
colors.muted,
)
if (
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
snapshot.selectedAddress != null
) {
TrainlogButton(
strings.getString(R.string.hr_diag_use_sensor),
{
val address = checkNotNull(snapshot.selectedAddress)
val name =
snapshot.devices
.firstOrNull { it.address == address }
?.name
diagnostics.close()
HeartRateSensorPreferences(context).select(address, name)
HeartRateSensorService.start(context)
onSelected()
},
Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SUCCESS,
)
}
}
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.hr_diag_scan),
{ scan(false) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
TrainlogButton(
strings.getString(R.string.hr_diag_scan_all),
{ scan(true) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
}
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
}
TrainlogFrame(
strings.getString(R.string.hr_diag_devices),
active = snapshot.devices.isNotEmpty(),
) {
if (snapshot.devices.isEmpty()) {
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
}
snapshot.devices.forEach { device ->
val title =
device.name?.takeIf { it.isNotBlank() }
?: strings.getString(R.string.hr_diag_unnamed_device)
val advertised =
strings.getString(
if (device.advertisesHeartRate) {
R.string.hr_diag_hrs_advertised
} else {
R.string.hr_diag_hrs_not_advertised
},
)
TrainlogAction(
title,
strings.getString(
R.string.hr_diag_device_description,
device.address,
device.rssi,
advertised,
),
{ diagnostics.connect(device.address) },
)
}
}
}
}

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateLivePhase
import com.labfytools.trainlog.data.HeartRateLiveState
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
internal fun HeartRateIndicator() {
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
DisposableEffect(Unit) {
val subscription = HeartRateLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val bpm =
live.bpm?.takeIf {
live.phase == HeartRateLivePhase.CONNECTED
}
val color =
when (live.phase) {
HeartRateLivePhase.CONNECTED -> colors.success
HeartRateLivePhase.CONNECTING,
HeartRateLivePhase.STALE -> colors.warning
HeartRateLivePhase.DISCONNECTED -> colors.muted
}
val beatPeriodMs =
bpm?.takeIf { it > 0 }
?.let { (60_000 / it).coerceIn(300, 2_000) }
?: 1_000
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
val scale by
transition.animateFloat(
initialValue = 1f,
targetValue = if (bpm != null) 1.14f else 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = beatPeriodMs / 2),
repeatMode = RepeatMode.Reverse,
),
label = "heart-rate-scale",
)
val description =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
HeartRateLivePhase.CONNECTING ->
strings.getString(R.string.heart_rate_a11y_connecting)
HeartRateLivePhase.STALE ->
strings.getString(R.string.heart_rate_a11y_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
strings.getString(R.string.heart_rate_a11y_disconnected)
}
Row(
modifier =
Modifier
.padding(horizontal = 4.dp)
.semantics { contentDescription = description },
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
androidx.compose.material3.Text(
"♥",
color = color,
fontSize = 20.sp,
modifier = Modifier.scale(scale),
)
androidx.compose.material3.Text(
bpm?.let { "$it BPM" } ?: "-- BPM",
color = color,
fontSize = 14.sp,
)
}
}

View file

@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -39,6 +40,8 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
@ -55,6 +58,7 @@ fun SessionsHub(
onDrafts: () -> Unit,
onHistory: () -> Unit,
onPrograms: () -> Unit = {},
onCardioCalibration: () -> Unit = {},
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -74,6 +78,13 @@ fun SessionsHub(
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
}
}
TrainlogButton(
strings.getString(R.string.route_cardio_calibration),
onCardioCalibration,
Modifier.fillMaxWidth().testTag("sessions-cardio-calibration-action"),
style = TrainlogButtonStyle.SUCCESS,
)
Spacer(Modifier.height(10.dp))
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action"))
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
@ -472,13 +483,22 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
).joinToString(", ")
@Composable
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
fun SettingsScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox,
onHeartRateDiagnostics: () -> Unit,
onCatalogChanged: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val context = LocalContext.current
val language = LocalLanguagePresentation.current
val strings = localizedContext()
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
var message by remember { mutableStateOf<String?>(null) }
val heartRatePreferences = remember(context) { HeartRateSensorPreferences(context) }
var heartRateRevision by remember { mutableIntStateOf(0) }
val selectedHeartRateSensor =
remember(heartRateRevision) { heartRatePreferences.selected() }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
authorized = inbox.hasFolderAccess()
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
@ -533,6 +553,29 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
)
}
}
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
if (selectedHeartRateSensor != null) {
TrainlogInfo(
strings.getString(R.string.settings_cardio_configured),
colors.success,
)
}
TrainlogAction(
strings.getString(R.string.settings_cardio_diagnostics),
strings.getString(R.string.settings_cardio_diagnostics_description),
onHeartRateDiagnostics,
)
if (selectedHeartRateSensor != null) {
TrainlogAction(
strings.getString(R.string.settings_cardio_forget),
strings.getString(R.string.settings_cardio_forget_description),
{
HeartRateSensorService.forget(context)
heartRateRevision++
},
)
}
}
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })

View file

@ -29,7 +29,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -49,6 +52,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.BuildConfig
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
@ -56,6 +61,7 @@ import com.labfytools.trainlog.data.FinalizeActiveDraftResult
import com.labfytools.trainlog.data.ManualPercentMaxResult
import com.labfytools.trainlog.data.SaveFeedbackResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseProfile
@ -71,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import java.text.Normalizer
import java.util.Locale
import com.labfytools.trainlog.R
/**
* CONTRACT: a private recovery build may override finalization time for one
* exact stable lifecycle identity only. Empty build inputs and every other
* draft retain the normal repository clock default.
*/
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
it.isNotEmpty()
}
@Composable
fun SessionScreen(
@ -152,6 +169,9 @@ fun SessionScreen(
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
var feedbackRevision by remember { mutableStateOf(0) }
var reorderedDuringGesture by remember { mutableStateOf(false) }
var timelineRevision by remember { mutableStateOf(0) }
var pendingExerciseSwitch by remember { mutableStateOf<Pair<String, String>?>(null) }
var pendingFinalizeActiveEntryId by remember { mutableStateOf<String?>(null) }
val persistDraft:
(ActiveSessionDraft, String?) -> Unit =
@ -191,6 +211,12 @@ fun SessionScreen(
}
val currentDraft = activeDraft!!
val exerciseTimings =
remember(timelineRevision, currentDraft.exercises.map { it.entryId }) {
repository.listActiveSessionExerciseTimings()
}
val timingByEntry = exerciseTimings.associateBy { it.entryId }
val activeTiming = exerciseTimings.firstOrNull { it.active }
val lastWriteFailed =
message?.startsWith(
@ -243,6 +269,19 @@ fun SessionScreen(
)
},
)
val cardio = currentDraft.sessionType == SessionType.CARDIO
TrainlogButton(
label = strings.getString(R.string.session_cardio),
modifier = Modifier.weight(1f),
style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(sessionType = SessionType.CARDIO),
null,
)
},
)
}
}
@ -266,23 +305,19 @@ fun SessionScreen(
) {
TrainlogInfo(
text =
strings.getString(R.string.session_type_value,
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
strings.getString(R.string.session_max_test)
} else {
strings.getString(R.string.session_training)
}),
strings.getString(
R.string.session_type_value,
when (currentDraft.sessionType) {
SessionType.TRAINING -> strings.getString(R.string.session_training)
SessionType.MAX_TEST -> strings.getString(R.string.session_max_test)
SessionType.CARDIO -> strings.getString(R.string.session_cardio)
},
),
color =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
colors.warning
} else {
colors.accent
when (currentDraft.sessionType) {
SessionType.MAX_TEST -> colors.warning
SessionType.CARDIO -> colors.success
SessionType.TRAINING -> colors.accent
},
)
@ -357,6 +392,98 @@ fun SessionScreen(
)
}
val timing = timingByEntry[draft.entryId]
when {
timing == null -> {
TrainlogButton(
label = strings.getString(R.string.exercise_timeline_start),
onClick = {
when (
val result =
repository.startActiveSessionExercise(draft.entryId)
) {
is SessionExerciseTimingResult.Started -> {
timelineRevision++
message =
strings.getString(
R.string.exercise_timeline_started,
draft.exercise.name,
)
}
is SessionExerciseTimingResult.ActiveConflict -> {
pendingExerciseSwitch =
result.activeEntryId to draft.entryId
}
is SessionExerciseTimingResult.Invalid ->
message = result.message
is SessionExerciseTimingResult.DatabaseError ->
message = result.message
is SessionExerciseTimingResult.Finished -> Unit
}
},
modifier =
Modifier
.fillMaxWidth()
.testTag("start-exercise-${draft.entryId}"),
style = TrainlogButtonStyle.SUCCESS,
)
}
timing.active -> {
TrainlogButton(
label = strings.getString(R.string.exercise_timeline_finish),
onClick = {
when (
val result =
repository.finishActiveSessionExercise(draft.entryId)
) {
is SessionExerciseTimingResult.Finished -> {
timelineRevision++
message =
strings.getString(
R.string.exercise_timeline_finished,
draft.exercise.name,
)
}
is SessionExerciseTimingResult.Invalid ->
message = result.message
is SessionExerciseTimingResult.DatabaseError ->
message = result.message
is SessionExerciseTimingResult.Started,
is SessionExerciseTimingResult.ActiveConflict -> Unit
}
},
modifier =
Modifier
.fillMaxWidth()
.testTag("finish-exercise-${draft.entryId}"),
containerColor = colors.warning,
style = TrainlogButtonStyle.SECONDARY,
)
}
else -> {
TrainlogButton(
label = strings.getString(R.string.exercise_timeline_completed),
onClick = {},
modifier =
Modifier
.fillMaxWidth()
.testTag("completed-exercise-${draft.entryId}"),
enabled = false,
style = TrainlogButtonStyle.SECONDARY,
)
}
}
if (
currentDraft.sessionType == SessionType.CARDIO &&
timing?.active == true
) {
CardioGuidancePanel(
repository = repository,
entryId = draft.entryId,
)
}
TrainlogAction(
label = strings.getString(R.string.modify_name, draft.exercise.name),
description = strings.getString(R.string.edit_exercise_description),
@ -518,22 +645,32 @@ fun SessionScreen(
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
containerColor = colors.surfaceAlt, maxLines = 2)
TrainlogButton(strings.getString(R.string.session_save), onClick = {
when (
val result =
repository.finalizeActiveSessionDraft()
) {
is FinalizeActiveDraftResult.Saved -> {
onSessionSaved()
onBack()
}
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
if (activeEntryId != null) {
pendingFinalizeActiveEntryId = activeEntryId
} else {
when (
val result =
repository.finalizeActiveSessionDraft(
recoveryEndedAt(currentDraft),
)
) {
is FinalizeActiveDraftResult.Saved -> {
onSessionSaved()
onBack()
}
is FinalizeActiveDraftResult.Invalid -> {
message = localizedRepositoryMessage(strings, result.message)
}
is FinalizeActiveDraftResult.Invalid -> {
message = localizedRepositoryMessage(strings, result.message)
}
is FinalizeActiveDraftResult.DatabaseError -> {
message =
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
is FinalizeActiveDraftResult.DatabaseError -> {
message =
strings.getString(
R.string.finalize_failed,
localizedRepositoryMessage(strings, result.message),
)
}
}
}
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
@ -589,7 +726,19 @@ fun SessionScreen(
strings.getString(R.string.exercise_added) ||
message ==
strings.getString(R.string.exercise_removed) ||
message == strings.getString(R.string.exercise_order_saved)
message == strings.getString(R.string.exercise_order_saved) ||
currentDraft.exercises.any { exercise ->
message ==
strings.getString(
R.string.exercise_timeline_started,
exercise.exercise.name,
) ||
message ==
strings.getString(
R.string.exercise_timeline_finished,
exercise.exercise.name,
)
}
) {
colors.success
} else {
@ -640,6 +789,142 @@ fun SessionScreen(
},
)
}
pendingExerciseSwitch?.let { (activeEntryId, targetEntryId) ->
val activeName =
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
?.exercise?.name
?: strings.getString(R.string.exercise_title)
val targetName =
currentDraft.exercises.firstOrNull { it.entryId == targetEntryId }
?.exercise?.name
?: strings.getString(R.string.exercise_title)
AlertDialog(
onDismissRequest = { pendingExerciseSwitch = null },
title = { Text(strings.getString(R.string.exercise_timeline_switch_title)) },
text = {
Text(
strings.getString(
R.string.exercise_timeline_switch_detail,
activeName,
targetName,
),
)
},
confirmButton = {
TextButton(
onClick = {
when (
val finished =
repository.finishActiveSessionExercise(activeEntryId)
) {
is SessionExerciseTimingResult.Finished -> {
when (
val started =
repository.startActiveSessionExercise(targetEntryId)
) {
is SessionExerciseTimingResult.Started -> {
timelineRevision++
message =
strings.getString(
R.string.exercise_timeline_started,
targetName,
)
}
is SessionExerciseTimingResult.Invalid ->
message = started.message
is SessionExerciseTimingResult.DatabaseError ->
message = started.message
is SessionExerciseTimingResult.ActiveConflict,
is SessionExerciseTimingResult.Finished -> Unit
}
}
is SessionExerciseTimingResult.Invalid ->
message = finished.message
is SessionExerciseTimingResult.DatabaseError ->
message = finished.message
is SessionExerciseTimingResult.Started,
is SessionExerciseTimingResult.ActiveConflict -> Unit
}
pendingExerciseSwitch = null
},
) {
Text(strings.getString(R.string.exercise_timeline_switch_confirm))
}
},
dismissButton = {
TextButton(onClick = { pendingExerciseSwitch = null }) {
Text(strings.getString(R.string.dialog_cancel))
}
},
)
}
pendingFinalizeActiveEntryId?.let { activeEntryId ->
val activeName =
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
?.exercise?.name
?: strings.getString(R.string.exercise_title)
AlertDialog(
onDismissRequest = { pendingFinalizeActiveEntryId = null },
title = { Text(strings.getString(R.string.exercise_timeline_finalize_title)) },
text = {
Text(
strings.getString(
R.string.exercise_timeline_finalize_detail,
activeName,
),
)
},
confirmButton = {
TextButton(
onClick = {
when (
val finished =
repository.finishActiveSessionExercise(activeEntryId)
) {
is SessionExerciseTimingResult.Finished -> {
timelineRevision++
when (
val result = repository.finalizeActiveSessionDraft(
recoveryEndedAt(currentDraft),
)
) {
is FinalizeActiveDraftResult.Saved -> {
pendingFinalizeActiveEntryId = null
onSessionSaved()
onBack()
}
is FinalizeActiveDraftResult.Invalid ->
message = localizedRepositoryMessage(strings, result.message)
is FinalizeActiveDraftResult.DatabaseError ->
message =
strings.getString(
R.string.finalize_failed,
localizedRepositoryMessage(strings, result.message),
)
}
}
is SessionExerciseTimingResult.Invalid ->
message = finished.message
is SessionExerciseTimingResult.DatabaseError ->
message = finished.message
is SessionExerciseTimingResult.Started,
is SessionExerciseTimingResult.ActiveConflict -> Unit
}
pendingFinalizeActiveEntryId = null
},
) {
Text(strings.getString(R.string.exercise_timeline_finalize_confirm))
}
},
dismissButton = {
TextButton(onClick = { pendingFinalizeActiveEntryId = null }) {
Text(strings.getString(R.string.dialog_cancel))
}
},
)
}
}
}

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.delay
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -37,8 +38,271 @@ import java.time.LocalDate
import java.time.OffsetDateTime
import java.util.UUID
@Composable
fun SleepDiaryScreen(repository: TrainlogRepository) {
var correctionMode by remember { mutableStateOf(false) }
if (correctionMode) {
Column(Modifier.fillMaxSize()) {
Button(
onClick = { correctionMode = false },
modifier = Modifier.fillMaxWidth().padding(12.dp),
) {
Text(stringResource(R.string.sleep_return_night_mode))
}
SleepDiaryEditorScreen(repository)
}
} else {
SleepNightCaptureScreen(repository) { correctionMode = true }
}
}
@Composable
private fun SleepNightCaptureScreen(
repository: TrainlogRepository,
onCorrection: () -> Unit,
) {
var revision by remember { mutableStateOf(0) }
var undoReceipt by remember {
mutableStateOf<TrainlogRepository.SleepQuickActionReceipt?>(null)
}
var message by remember { mutableStateOf<String?>(null) }
val strings = localizedContext()
val entries = remember(revision) { repository.listSleepDiary() }
val active =
entries.firstOrNull { entry ->
entry.events.any { it.type == SleepEventType.BED_TIME } &&
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
}
val pending =
entries.firstOrNull { entry ->
entry.events.none { it.type == SleepEventType.BED_TIME } &&
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
}
val visibleNight = active ?: pending
val medications =
remember(revision) {
repository.listSleepMedications(includeInactive = false)
}
LaunchedEffect(undoReceipt?.appliedRevisionId) {
if (undoReceipt != null) {
delay(10_000)
undoReceipt = null
}
}
fun apply(
successMessage: String,
result: TrainlogRepository.SleepQuickActionResult,
) {
when (result) {
is TrainlogRepository.SleepQuickActionResult.Applied -> {
undoReceipt = result.receipt
message = successMessage
revision++
}
TrainlogRepository.SleepQuickActionResult.NoActiveNight ->
message = strings.getString(R.string.sleep_quick_no_active)
TrainlogRepository.SleepQuickActionResult.AlreadyActive ->
message = strings.getString(R.string.sleep_quick_already_active)
TrainlogRepository.SleepQuickActionResult.InvalidMedication ->
message = strings.getString(R.string.sleep_quick_medication_unavailable)
TrainlogRepository.SleepQuickActionResult.Conflict ->
message = strings.getString(R.string.sleep_quick_conflict)
TrainlogRepository.SleepQuickActionResult.Error ->
message = strings.getString(R.string.sleep_quick_error)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(stringResource(R.string.sleep_night_capture_title))
}
item {
visibleNight?.let { night ->
Text(
stringResource(
R.string.sleep_night_in_progress,
night.nightStartDate,
night.nightEndDate,
),
)
} ?: Text(stringResource(R.string.sleep_no_night_active))
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_bed_recorded),
repository.quickSleepBedTime(),
)
},
enabled = active == null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_bed))
}
}
if (medications.isNotEmpty()) {
item { Text(stringResource(R.string.sleep_medications)) }
items(medications, key = { it.medicationId }) { medication ->
var quantity by remember(medication.medicationId) { mutableStateOf(1) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(onClick = { if (quantity > 1) quantity-- }) { Text("−") }
Text("×$quantity", modifier = Modifier.padding(top = 12.dp))
Button(onClick = { if (quantity < 99) quantity++ }) { Text("+") }
Button(
onClick = {
val selectedQuantity = quantity
val result =
repository.quickSleepMedication(
medication.medicationId,
quantity = selectedQuantity,
)
apply(
strings.getString(
R.string.sleep_medication_recorded,
medication.name +
if (selectedQuantity > 1) " ×$selectedQuantity" else "",
),
result,
)
if (result is TrainlogRepository.SleepQuickActionResult.Applied) {
quantity = 1
}
},
modifier = Modifier.weight(1f),
) {
val dose =
medication.defaultDoseValue?.let { doseValue ->
" · " + doseValue.toString().trimEnd('0').trimEnd('.') +
" " + medication.defaultDoseUnit.orEmpty()
}.orEmpty()
Text("💊 " + medication.name + dose)
}
}
}
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_wake_recorded),
repository.quickSleepWake(),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_wake))
}
}
item {
Button(
onClick = {
apply(
strings.getString(R.string.sleep_get_up_recorded),
repository.quickSleepFinalGetUp(),
)
},
enabled = active != null,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_quick_get_up))
}
}
message?.let { text ->
item {
Card {
Column(
Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text)
undoReceipt?.let { receipt ->
Button(
onClick = {
when (repository.undoSleepQuickAction(receipt)) {
is TrainlogRepository.SleepQuickActionResult.Applied -> {
message = strings.getString(R.string.sleep_action_undone)
undoReceipt = null
revision++
}
else -> {
message = strings.getString(R.string.sleep_undo_unavailable)
undoReceipt = null
revision++
}
}
},
) {
Text(stringResource(R.string.sleep_undo))
}
}
}
}
}
}
visibleNight?.let { night ->
item {
Card {
Column(
Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(stringResource(R.string.sleep_night_timeline))
night.events.sortedBy { it.startAt }.forEach { event ->
Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt))
}
night.intakes.sortedBy { it.takenAt }.forEach { intake ->
Text(
"💊 " + intake.medicationName +
(if (intake.quantity > 1) " ×${intake.quantity}" else "") +
" · " + shortTime(intake.takenAt),
)
}
}
}
}
}
item {
Button(
onClick = onCorrection,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.sleep_correct_night))
}
}
}
}
private fun shortTime(value: String): String =
runCatching { OffsetDateTime.parse(value).toLocalTime().withSecond(0).withNano(0).toString() }
.getOrDefault(value)
private fun eventLabelPlain(type: SleepEventType, context: android.content.Context): String =
context.getString(
when (type) {
SleepEventType.BED_TIME -> R.string.sleep_bed_time
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
SleepEventType.SLEEP -> R.string.sleep_sleep
SleepEventType.NAP -> R.string.sleep_nap
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
},
)
@Composable
private fun SleepDiaryEditorScreen(repository: TrainlogRepository) {
var revision by remember { mutableStateOf(0) }
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
val today = LocalDate.now()

View file

@ -103,8 +103,16 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
},
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
AppRoute.Sessions -> SessionsHub(
activeDraft,
pendingAiDraftCount,
{ open(AppRoute.SessionEditor) },
::openManualSession,
{ open(AppRoute.AiSessionDrafts) },
{ open(AppRoute.CompletedSessions) },
{ open(AppRoute.Programs) },
{ open(AppRoute.CardioCalibration) },
)
AppRoute.SessionEditor -> SessionScreen(
repository,
catalogRevision,
@ -137,6 +145,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
)
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
draftRevision++
exportSnapshot()
}
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
draftRevision++
open(AppRoute.SessionEditor)
@ -195,7 +207,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
}}
}

View file

@ -480,4 +480,141 @@
<string name="sleep_unit">Unit</string>
<string name="sleep_deactivate">Deactivate</string>
<string name="sleep_edit">Edit</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
<string name="hr_diag_status">Sensor status</string>
<string name="hr_diag_idle">Ready</string>
<string name="hr_diag_scanning">Scanning…</string>
<string name="hr_diag_connecting">Connecting…</string>
<string name="hr_diag_discovering">Discovering services…</string>
<string name="hr_diag_subscribing">Enabling measurements…</string>
<string name="hr_diag_receiving">Measurements received</string>
<string name="hr_diag_disconnected">Disconnected</string>
<string name="hr_diag_error">BLE error</string>
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
<string name="hr_diag_technical">Diagnostic: %1$s</string>
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
<string name="hr_diag_notifications">Notifications received: %1$d</string>
<string name="hr_diag_scan_section">BLE scan</string>
<string name="hr_diag_scan">Find heart-rate</string>
<string name="hr_diag_scan_all">Expanded scan</string>
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
<string name="hr_diag_devices">BLE devices</string>
<string name="hr_diag_no_devices">No device found yet.</string>
<string name="hr_diag_unnamed_device">Unnamed device</string>
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
<string name="hr_diag_use_sensor">Use this sensor</string>
<string name="heart_rate_service_channel">Heart-rate sensor</string>
<string name="heart_rate_service_title">Trainlog · cardio</string>
<string name="heart_rate_service_connecting">Connecting to sensor…</string>
<string name="heart_rate_service_reconnecting">Waiting for heart-rate signal…</string>
<string name="heart_rate_service_disconnected">Heart-rate sensor disconnected</string>
<string name="heart_rate_service_bpm">%1$d BPM</string>
<string name="heart_rate_a11y_connected">Heart rate %1$d BPM, sensor connected</string>
<string name="heart_rate_a11y_connecting">Heart-rate sensor connecting</string>
<string name="heart_rate_a11y_reconnecting">Heart-rate signal temporarily lost, reconnecting</string>
<string name="heart_rate_a11y_disconnected">Heart-rate sensor disconnected</string>
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
<string name="settings_cardio_forget">Forget sensor</string>
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
<string name="exercise_timeline_start">Start exercise</string>
<string name="exercise_timeline_finish">Finish exercise</string>
<string name="exercise_timeline_completed">Exercise completed</string>
<string name="exercise_timeline_started">%1$s · started</string>
<string name="exercise_timeline_finished">%1$s · finished</string>
<string name="exercise_timeline_switch_title">Switch exercise?</string>
<string name="exercise_timeline_switch_detail">%1$s is still active. Finish it now and then start %2$s?</string>
<string name="exercise_timeline_switch_confirm">Finish and start</string>
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
<string name="sleep_night_capture_title">Night capture</string>
<string name="sleep_no_night_active">No night in progress.</string>
<string name="sleep_night_in_progress">Night in progress · %1$s → %2$s</string>
<string name="sleep_quick_bed">🛏 BED</string>
<string name="sleep_quick_wake">👁 WAKE</string>
<string name="sleep_quick_get_up">☀ GET UP</string>
<string name="sleep_bed_recorded">Bed time recorded.</string>
<string name="sleep_wake_recorded">30-minute awakening recorded.</string>
<string name="sleep_get_up_recorded">Get-up recorded.</string>
<string name="sleep_medication_recorded">%1$s recorded.</string>
<string name="sleep_undo">UNDO</string>
<string name="sleep_action_undone">Last action undone.</string>
<string name="sleep_undo_unavailable">Undo unavailable: the night has already changed.</string>
<string name="sleep_correct_night">Correct the night</string>
<string name="sleep_return_night_mode">Back to night capture</string>
<string name="sleep_night_timeline">Night timeline</string>
<string name="sleep_quick_no_active">No night in progress.</string>
<string name="sleep_quick_already_active">A night is already in progress.</string>
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
<string name="sleep_quick_error">Unable to record the action.</string>
<string name="session_cardio">Cardio</string>
<string name="route_cardio_calibration">Cardio calibration</string>
<string name="calibration_explanation">This session measures your current heart-rate response. Effort increases progressively and you can stop immediately at any time. The result is stored as an observed heart-rate peak, not as a medical maximum heart rate.</string>
<string name="calibration_start">Start calibration</string>
<string name="calibration_start_description">Create a dedicated cardio session, start heart-rate capture and begin with warm-up.</string>
<string name="calibration_latest">Latest calibration</string>
<string name="calibration_current">Calibration in progress</string>
<string name="calibration_phase_value">Phase: %1$s</string>
<string name="calibration_phase_warmup">Warm-up</string>
<string name="calibration_phase_progressive">Progressive effort</string>
<string name="calibration_phase_high_effort">High effort</string>
<string name="calibration_phase_recovery">Recovery</string>
<string name="calibration_phase_completed">Completed</string>
<string name="calibration_phase_aborted">Stopped</string>
<string name="calibration_peak_value">Observed peak: %1$d BPM</string>
<string name="calibration_recovery_value">Recovery +%1$d min: %2$d BPM</string>
<string name="calibration_to_progressive">Start progressive effort</string>
<string name="calibration_progressive_description">Increase effort progressively without trying to beat a previous value.</string>
<string name="calibration_to_high_effort">Move to high effort</string>
<string name="calibration_high_effort_description">Continue only while you choose to tolerate the effort. The next button ends the effort phase immediately.</string>
<string name="calibration_end_effort">Stop effort</string>
<string name="calibration_end_effort_description">Mark the end of effort and begin measured recovery.</string>
<string name="calibration_finish">Finish calibration</string>
<string name="calibration_finish_description">Close recovery and save the calibration profile.</string>
<string name="calibration_abort">Stop calibration</string>
<string name="calibration_conflict">Another session or calibration is already active.</string>
<string name="cardio_guidance_title">Cardio guidance</string>
<string name="cardio_guidance_phase_value">Phase: %1$s</string>
<string name="cardio_guidance_target_value">Target: %1$d–%2$d BPM</string>
<string name="cardio_guidance_calibration_value">Calibration: observed peak %1$d BPM · %2$d–%3$d %%</string>
<string name="cardio_guidance_latest_calibration">Latest calibration: observed peak %1$d BPM</string>
<string name="cardio_guidance_resolved_target">Resolved target: %1$d–%2$d BPM</string>
<string name="cardio_guidance_accelerate">SPEED UP</string>
<string name="cardio_guidance_maintain">MAINTAIN</string>
<string name="cardio_guidance_slow_down">SLOW DOWN</string>
<string name="cardio_guidance_suspended">HEART-RATE SIGNAL UNAVAILABLE</string>
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
<string name="cardio_guidance_bpm_missing">-- BPM</string>
<string name="cardio_guidance_start_phase">Start phase</string>
<string name="cardio_guidance_finish_phase">End phase</string>
<string name="cardio_guidance_invalid_configuration">Complete the target and exit condition.</string>
<string name="cardio_guidance_start_failed">Unable to start this phase.</string>
<string name="cardio_phase_warmup">Warm-up</string>
<string name="cardio_phase_work">Work</string>
<string name="cardio_phase_recovery">Recovery</string>
<string name="cardio_phase_cooldown">Cool-down</string>
<string name="cardio_target_absolute">BPM</string>
<string name="cardio_target_calibration">Calibration %%</string>
<string name="cardio_target_min_bpm">Min BPM</string>
<string name="cardio_target_max_bpm">Max BPM</string>
<string name="cardio_target_min_percent">Min %%</string>
<string name="cardio_target_max_percent">Max %%</string>
<string name="cardio_exit_duration">Duration</string>
<string name="cardio_exit_target">Enter target</string>
<string name="cardio_exit_recover">Below threshold</string>
<string name="cardio_exit_bounded">Duration or threshold</string>
<string name="cardio_exit_seconds">Max duration</string>
<string name="cardio_exit_recovery_bpm">Recovery threshold</string>
</resources>

View file

@ -490,4 +490,141 @@
<string name="sleep_unit">Unité</string>
<string name="sleep_deactivate">Désactiver</string>
<string name="sleep_edit">Modifier</string>
<string name="yes">Oui</string>
<string name="no">Non</string>
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
<string name="hr_diag_status">État du capteur</string>
<string name="hr_diag_idle">Prêt</string>
<string name="hr_diag_scanning">Recherche en cours…</string>
<string name="hr_diag_connecting">Connexion…</string>
<string name="hr_diag_discovering">Découverte des services…</string>
<string name="hr_diag_subscribing">Activation des mesures…</string>
<string name="hr_diag_receiving">Mesures reçues</string>
<string name="hr_diag_disconnected">Déconnecté</string>
<string name="hr_diag_error">Erreur BLE</string>
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
<string name="hr_diag_technical">Diagnostic : %1$s</string>
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
<string name="hr_diag_scan_section">Recherche BLE</string>
<string name="hr_diag_scan">Rechercher cardio</string>
<string name="hr_diag_scan_all">Recherche élargie</string>
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
<string name="hr_diag_devices">Périphériques BLE</string>
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
<string name="hr_diag_use_sensor">Utiliser ce capteur</string>
<string name="heart_rate_service_channel">Capteur cardio</string>
<string name="heart_rate_service_title">Trainlog · cardio</string>
<string name="heart_rate_service_connecting">Connexion au capteur…</string>
<string name="heart_rate_service_reconnecting">Signal cardio en attente…</string>
<string name="heart_rate_service_disconnected">Capteur cardio déconnecté</string>
<string name="heart_rate_service_bpm">%1$d BPM</string>
<string name="heart_rate_a11y_connected">Fréquence cardiaque %1$d BPM, capteur connecté</string>
<string name="heart_rate_a11y_connecting">Capteur cardio en connexion</string>
<string name="heart_rate_a11y_reconnecting">Signal cardio temporairement perdu, reconnexion en cours</string>
<string name="heart_rate_a11y_disconnected">Capteur cardio déconnecté</string>
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
<string name="settings_cardio_forget">Oublier le capteur</string>
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
<string name="exercise_timeline_start">Démarrer l’exercice</string>
<string name="exercise_timeline_finish">Terminer l’exercice</string>
<string name="exercise_timeline_completed">Exercice terminé</string>
<string name="exercise_timeline_started">%1$s · démarré</string>
<string name="exercise_timeline_finished">%1$s · terminé</string>
<string name="exercise_timeline_switch_title">Changer d’exercice ?</string>
<string name="exercise_timeline_switch_detail">%1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ?</string>
<string name="exercise_timeline_switch_confirm">Terminer et démarrer</string>
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
<string name="sleep_night_capture_title">Saisie nocturne</string>
<string name="sleep_no_night_active">Aucune nuit en cours.</string>
<string name="sleep_night_in_progress">Nuit en cours · %1$s → %2$s</string>
<string name="sleep_quick_bed">🛏 COUCHÉ</string>
<string name="sleep_quick_wake">👁 RÉVEIL</string>
<string name="sleep_quick_get_up">☀ LEVÉ</string>
<string name="sleep_bed_recorded">Couché enregistré.</string>
<string name="sleep_wake_recorded">Réveil de 30 minutes enregistré.</string>
<string name="sleep_get_up_recorded">Levé enregistré.</string>
<string name="sleep_medication_recorded">%1$s enregistré.</string>
<string name="sleep_undo">ANNULER</string>
<string name="sleep_action_undone">Dernière action annulée.</string>
<string name="sleep_undo_unavailable">Annulation impossible : la nuit a déjà changé.</string>
<string name="sleep_correct_night">Corriger la nuit</string>
<string name="sleep_return_night_mode">Retour à la saisie nocturne</string>
<string name="sleep_night_timeline">Chronologie de la nuit</string>
<string name="sleep_quick_no_active">Aucune nuit en cours.</string>
<string name="sleep_quick_already_active">Une nuit est déjà en cours.</string>
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
<string name="sleep_quick_error">Enregistrement impossible.</string>
<string name="session_cardio">Cardio</string>
<string name="route_cardio_calibration">Calibration cardio</string>
<string name="calibration_explanation">Cette séance mesure votre réponse cardiaque actuelle. L’effort augmente progressivement et vous pouvez arrêter immédiatement à tout moment. Le résultat est enregistré comme un pic de fréquence cardiaque observé, pas comme une FC max médicale.</string>
<string name="calibration_start">Démarrer la calibration</string>
<string name="calibration_start_description">Créer une séance cardio dédiée, démarrer la capture cardiaque et commencer par l’échauffement.</string>
<string name="calibration_latest">Dernière calibration</string>
<string name="calibration_current">Calibration en cours</string>
<string name="calibration_phase_value">Phase : %1$s</string>
<string name="calibration_phase_warmup">Échauffement</string>
<string name="calibration_phase_progressive">Montée progressive</string>
<string name="calibration_phase_high_effort">Effort élevé</string>
<string name="calibration_phase_recovery">Récupération</string>
<string name="calibration_phase_completed">Terminée</string>
<string name="calibration_phase_aborted">Arrêtée</string>
<string name="calibration_peak_value">Pic observé : %1$d BPM</string>
<string name="calibration_recovery_value">Récupération +%1$d min : %2$d BPM</string>
<string name="calibration_to_progressive">Commencer la montée progressive</string>
<string name="calibration_progressive_description">Augmenter progressivement l’effort sans chercher à battre une ancienne valeur.</string>
<string name="calibration_to_high_effort">Passer à l’effort élevé</string>
<string name="calibration_high_effort_description">Continuer seulement tant que vous choisissez de tolérer l’effort. Le bouton suivant arrête immédiatement la phase d’effort.</string>
<string name="calibration_end_effort">Arrêter l’effort</string>
<string name="calibration_end_effort_description">Marquer la fin de l’effort et commencer la récupération mesurée.</string>
<string name="calibration_finish">Terminer la calibration</string>
<string name="calibration_finish_description">Fermer la récupération et enregistrer le profil de calibration.</string>
<string name="calibration_abort">Arrêter la calibration</string>
<string name="calibration_conflict">Une autre séance ou calibration est déjà en cours.</string>
<string name="cardio_guidance_title">Guidage cardio</string>
<string name="cardio_guidance_phase_value">Phase : %1$s</string>
<string name="cardio_guidance_target_value">Cible : %1$d–%2$d BPM</string>
<string name="cardio_guidance_calibration_value">Calibration : pic observé %1$d BPM · %2$d–%3$d %%</string>
<string name="cardio_guidance_latest_calibration">Dernière calibration : pic observé %1$d BPM</string>
<string name="cardio_guidance_resolved_target">Cible utilisée : %1$d–%2$d BPM</string>
<string name="cardio_guidance_accelerate">ACCÉLÈRE</string>
<string name="cardio_guidance_maintain">MAINTIENS</string>
<string name="cardio_guidance_slow_down">RALENTIS</string>
<string name="cardio_guidance_suspended">SIGNAL CARDIO INDISPONIBLE</string>
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
<string name="cardio_guidance_bpm_missing">-- BPM</string>
<string name="cardio_guidance_start_phase">Démarrer la phase</string>
<string name="cardio_guidance_finish_phase">Terminer la phase</string>
<string name="cardio_guidance_invalid_configuration">Complète la cible et la condition de fin.</string>
<string name="cardio_guidance_start_failed">Impossible de démarrer cette phase.</string>
<string name="cardio_phase_warmup">Échauffement</string>
<string name="cardio_phase_work">Travail</string>
<string name="cardio_phase_recovery">Récupération</string>
<string name="cardio_phase_cooldown">Retour au calme</string>
<string name="cardio_target_absolute">BPM</string>
<string name="cardio_target_calibration">%% calibration</string>
<string name="cardio_target_min_bpm">BPM min</string>
<string name="cardio_target_max_bpm">BPM max</string>
<string name="cardio_target_min_percent">%% min</string>
<string name="cardio_target_max_percent">%% max</string>
<string name="cardio_exit_duration">Durée</string>
<string name="cardio_exit_target">Entrée dans la cible</string>
<string name="cardio_exit_recover">Sous un seuil</string>
<string name="cardio_exit_bounded">Durée ou seuil</string>
<string name="cardio_exit_seconds">Durée max</string>
<string name="cardio_exit_recovery_bpm">Seuil de récupération</string>
</resources>

View file

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

View file

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

View file

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

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(33, 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(26, it.version) }
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -0,0 +1,54 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV27HeartRateMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v26AddsEmptyHeartRateDomainAndReopens() {
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
JSONObject(it.buildHeartRateV1Json())
it.close()
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE heart_rate_generation_captures")
db.execSQL("DROP TABLE heart_rate_rr_intervals")
db.execSQL("DROP TABLE heart_rate_samples")
db.execSQL("DROP TABLE heart_rate_captures")
db.version = 26
}
var repository = TrainlogRepository(context, name)
try {
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
.getJSONArray("captures").length())
repository.close()
repository = TrainlogRepository(context, name)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,105 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV28SessionTimelineMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() {
val name = "timeline-v27-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(
ActiveDraftMutationResult.Saved,
repository.startActiveSessionDraft(),
)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Timeline migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(loaded.draft.sessionId)
val startedAt = checkNotNull(loaded.draft.startedAt)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
loaded.draft.copy(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE session_timeline_generation_sessions")
db.execSQL("DROP TABLE session_timeline_exercises")
db.execSQL("DROP TABLE session_timeline_sessions")
db.version = 27
}
repository = TrainlogRepository(context, name)
assertNotNull(repository.activeSessionTimelineContext())
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(sessionId, cursor.getString(0))
assertEquals(startedAt, cursor.getString(1))
assertTrue(cursor.isNull(2))
}
db.rawQuery(
"SELECT COUNT(*) FROM session_timeline_exercises",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getInt(0))
}
}
assertNotNull(repository.activeSessionTimelineContext())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,108 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV29CardioSessionMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() {
val name = "cardio-v28-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertTrue(
repository.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
),
),
),
) is SaveSessionResult.Saved,
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 6)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("UPDATE sessions SET session_kind='cardio'")
db.execSQL("UPDATE active_session_draft SET session_kind='cardio'")
db.version = 28
}
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listSessions().size)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("training", cursor.getString(1))
}
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("training", cursor.getString(1))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,69 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV30CardioCalibrationMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v29AddsEmptyCalibrationDomainAndReservedExercise() {
val name = "calibration-v29-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listExercises()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_calibration_generation")
db.execSQL("DROP TABLE cardio_calibration_recovery")
db.execSQL("DROP TABLE cardio_calibrations")
db.version = 29
}
repository = TrainlogRepository(context, name)
assertTrue(repository.listExercises().none { it.name == "Calibration cardio" })
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
"AND name='Calibration cardio'",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
}
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_calibrations','cardio_calibration_recovery'," +
"'cardio_calibration_generation')",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(3, cursor.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,65 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV31CardioGuidanceMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v30AddsEmptyGuidanceDomainAndReopens() {
val name = "guidance-v30-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listSessions()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_guidance_generation")
db.execSQL("DROP TABLE cardio_guidance_events")
db.execSQL("DROP TABLE cardio_guidance_phases")
db.execSQL("DROP TABLE cardio_guidance_runs")
db.version = 30
}
repository = TrainlogRepository(context, name)
assertTrue(repository.activeCardioGuidancePhase() == null)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," +
"'cardio_guidance_events','cardio_guidance_generation')",
null,
).use { c ->
assertTrue(c.moveToFirst())
assertEquals(4, c.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM cardio_guidance_runs", null).use { c ->
assertTrue(c.moveToFirst())
assertEquals(0, c.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV32SleepQuantityMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v31IntakesHydrateToOneAndQuickPublicationStartsEmpty() {
val name = "sleep-quantity-v31-${UUID.randomUUID()}.db"
val initial = TrainlogRepository(context, name)
try {
val saved = initial.saveSleepMedication(
SleepMedication(
"", "", "2026-09-23T20:00:00+02:00", "2026-09-23T20:00:00+02:00",
"Migration medication", 75.0, "mg", "", "", true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
initial.quickSleepMedication(saved.entryId, "2026-09-23T21:00:00+02:00")
} finally {
initial.close()
}
val path = context.getDatabasePath(name).path
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("PRAGMA foreign_keys=OFF")
db.execSQL("DROP TABLE sleep_diary_quick_publication_state")
db.execSQL("ALTER TABLE sleep_medication_intakes RENAME TO sleep_medication_intakes_v32")
db.execSQL(
"""CREATE TABLE sleep_medication_intakes(
revision_id TEXT NOT NULL, intake_id TEXT NOT NULL,
medication_id TEXT NOT NULL, medication_name TEXT NOT NULL,
taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT,
note TEXT, created_at TEXT NOT NULL,
PRIMARY KEY(revision_id,intake_id)
)""".trimIndent(),
)
db.execSQL(
"INSERT INTO sleep_medication_intakes SELECT revision_id,intake_id,medication_id," +
"medication_name,taken_at,dose_value,dose_unit,note,created_at " +
"FROM sleep_medication_intakes_v32",
)
db.execSQL("DROP TABLE sleep_medication_intakes_v32")
db.execSQL("PRAGMA user_version=31")
}
val migrated = TrainlogRepository(context, name)
migrated.listSleepDiary()
migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(
1,
scalar(
db,
"SELECT COUNT(*) FROM pragma_table_info('sleep_medication_intakes') " +
"WHERE name='quantity' AND \"notnull\"=1 AND dflt_value='1'",
),
)
assertEquals(1, scalar(db, "SELECT quantity FROM sleep_medication_intakes"))
assertEquals(
1,
scalar(
db,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' " +
"AND name='sleep_diary_quick_publication_state'",
),
)
assertEquals("ok", text(db, "PRAGMA integrity_check"))
}
context.deleteDatabase(name)
}
private fun scalar(db: SQLiteDatabase, sql: String): Int =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
private fun text(db: SQLiteDatabase, sql: String): String =
db.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
}

View file

@ -0,0 +1,38 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BluetoothSyncTransportContractTest {
@Test
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
assertTrue(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = false,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = false,
)
)
}
@Test
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
assertTrue(
BLUETOOTH_ANDROID_PULL_COORDINATION.contains(
"android-archive-acknowledgements-v1.json"
)
)
}
}

View file

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

View file

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

View file

@ -0,0 +1,187 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.model.SessionType
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioCalibrationRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
val name = "calibration-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val started =
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:00:10+02:00",
measurement(80),
)
assertEquals(
CardioCalibrationPhase.PROGRESSIVE,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:01:20+02:00",
measurement(112),
)
assertEquals(
CardioCalibrationPhase.HIGH_EFFORT,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:02:30+02:00",
measurement(150),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:03:00+02:00",
measurement(160),
)
val recovery =
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
assertEquals(160, recovery.profile.observedPeakBpm)
assertTrue(recovery.profile.heartRateCaptureId != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:04:01+02:00",
measurement(140),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:05:02+02:00",
measurement(120),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:06:01+02:00",
measurement(100),
)
val completed =
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
assertEquals(
listOf(60 to 140, 120 to 120, 180 to 100),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
assertNull(repository.activeCardioCalibration())
assertNull(repository.activeHeartRateCapture())
val session = repository.listSessions().single()
assertEquals(SessionType.CARDIO, session.sessionType)
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
assertEquals(1, repository.listCompletedCardioCalibrations().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:00:10+02:00",
measurement(80),
)
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:02:30+02:00",
measurement(155),
)
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:05:05+02:00",
measurement(118),
)
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
assertEquals(
listOf(120 to 118),
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
)
val tooEarly =
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
assertTrue(repository.activeCardioCalibration() != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:06:03+02:00",
measurement(105),
)
val completed =
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(
listOf(120 to 118, 180 to 105),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
val aborted =
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
assertNull(aborted.profile.effortEndAt)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,147 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioGuidanceEngineTest {
private val target = CardioTargetSnapshot(120, 140)
@Test
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
assertEquals(
CardioTargetSnapshot(120, 140),
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
)
assertEquals(
CardioTargetSnapshot(
minimumBpm = 100,
maximumBpm = 125,
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
calibrationObservedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
observedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
),
)
}
@Test
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
),
)
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
),
)
}
@Test
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 110, true, 1_000).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 111, true, 3_999).instruction,
)
val changed = engine.evaluate(target, 111, true, 4_000)
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
assertNull(changed.pendingInstruction)
}
@Test
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
engine.evaluate(target, 150, true, 0)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 150, true, 2_000).instruction,
)
}
@Test
fun hysteresisPreventsBoundaryYoyo() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 117, true, 0).instruction,
)
// 119 is still below target, but not enough to leave ACCELERATE.
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 119, true, 1).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 122, true, 2).instruction,
)
// 141 is just above the zone and remains MAINTAIN within hysteresis.
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 141, true, 3).instruction,
)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 143, true, 4).instruction,
)
}
@Test
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
engine.evaluate(target, 110, true, 0)
engine.evaluate(target, 110, true, 3_000)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 150, true, 3_100).instruction,
)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 112, true, 3_200).instruction,
)
}
@Test
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 110, true, 0).instruction,
)
val stale = engine.evaluate(target, 110, false, 1_000)
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
assertNull(stale.bpm)
// A fresh sample must re-establish a candidate from real current data.
val resumed = engine.evaluate(target, 130, true, 2_000)
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
assertEquals(130, resumed.bpm)
}
@Test
fun noTargetSuspendsGuidance() {
val engine = CardioGuidanceEngine()
val output = engine.evaluate(null, 130, true, 0)
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
assertNull(output.bpm)
}
}

View file

@ -0,0 +1,260 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé sync",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
val exerciseStart = base.plusSeconds(1).toString()
assertTrue(
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(2).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val lowAt = base.plusSeconds(3).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lowAt,
measurement(110),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
lowAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
val targetAt = base.plusSeconds(4).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
targetAt,
measurement(130),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
targetAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
repository.finishCardioGuidancePhase(
phase.phaseId,
base.plusSeconds(5).toString(),
)
repository.finishActiveSessionExercise(
entry.entryId,
base.plusSeconds(6).toString(),
)
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == entry.entryId) {
it.copy(continuousDurationSeconds = 5)
} else {
it
}
},
),
),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
1,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(repository)
val captured = service.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-guidance-v1.json",
).readText(),
)
.getJSONArray("runs")
.length(),
)
val published =
service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
assertEquals(
"consumed",
JSONObject(ackFile.readText()).getString("result"),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
0,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
assertEquals(
"1|1|2",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_events);",
)
.trim(),
)
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,428 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private data class ActiveCardio(
val repository: TrainlogRepository,
val databaseName: String,
val entryId: String,
val exerciseStartedAt: String,
) {
fun at(seconds: Long): String =
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
}
private fun activeCardio(): ActiveCardio {
val name = "guidance-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Tapis guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val exerciseStartedAt =
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
.plusSeconds(10)
.toString()
val started =
repository.startActiveSessionExercise(
entry.entryId,
exerciseStartedAt,
)
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
}
@Test
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé après transition",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val training =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 101,
sensorContactDetected = true,
energyExpended = 12,
rrIntervals1024 = listOf(611),
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
base.plusSeconds(1).toString(),
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
)
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
)
val cardio =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
)
assertTrue(
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(100, 110)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WARMUP,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(3).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val secondAt = base.plusSeconds(4).toString()
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
secondAt,
firstMeasurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.recordCardioGuidanceOutput(
phase.phaseId,
secondAt,
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
assertEquals(
listOf(CardioGuidanceInstruction.ACCELERATE),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
val lateReverse =
repository.saveActiveSessionDraft(
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.copy(sessionType = SessionType.TRAINING),
)
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
assertEquals(
SessionType.CARDIO,
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.sessionType,
)
assertEquals(
HeartRateContextKind.CARDIO,
checkNotNull(repository.activeHeartRateCapture()).contextKind,
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
"WHERE capture_id=? ORDER BY sequence",
arrayOf(originalCapture.captureId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0L, cursor.getLong(0))
assertEquals(101, cursor.getInt(1))
assertTrue(cursor.isNull(2))
assertTrue(cursor.moveToNext())
assertEquals(1L, cursor.getLong(0))
assertEquals(99, cursor.getInt(1))
assertEquals(entry.entryId, cursor.getString(2))
assertTrue(!cursor.moveToNext())
}
}
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
assertNull(repository.activeHeartRateCapture())
assertNull(repository.activeCardioGuidancePhase())
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val training =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val observedAt =
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
observedAt,
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
)
val cardio =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
)
val reversed = checkNotNull(repository.activeHeartRateCapture())
assertEquals(captureId, reversed.captureId)
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
arrayOf(captureId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
assertEquals(103, cursor.getInt(1))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
val active = activeCardio()
val repository = active.repository
try {
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
)
val started =
repository.startCardioGuidancePhase(
active.entryId,
phase,
active.at(10),
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(13),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(14),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
111,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(30),
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
assertEquals(
listOf(
CardioGuidanceInstruction.ACCELERATE,
CardioGuidanceInstruction.MAINTAIN,
),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
repository.finishActiveSessionExercise(
active.entryId,
active.at(600),
)
assertNull(repository.activeCardioGuidancePhase())
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == active.entryId) {
it.copy(continuousDurationSeconds = 600)
} else {
it
}
},
),
),
)
val finalized = repository.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
@Test
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
repository.startActiveSessionDraft()
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
repository.saveActiveSessionDraft(
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
)
val result =
repository.startCardioGuidancePhase(
entry.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WARMUP,
CardioTargetSnapshot(100, 115),
CardioPhaseExitCondition.FixedDuration(60),
),
"2026-09-23T13:00:00+02:00",
)
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
val active = activeCardio()
val repository = active.repository
try {
repository.startCardioGuidancePhase(
active.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WORK,
CardioTargetSnapshot(120, 140),
CardioPhaseExitCondition.FixedDuration(120),
),
active.at(10),
)
assertTrue(repository.activeCardioGuidancePhase() != null)
repository.discardActiveSessionDraft()
assertNull(repository.activeCardioGuidancePhase())
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
}

View file

@ -0,0 +1,67 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioPhaseCompletionTest {
private val target = CardioTargetSnapshot(120, 140)
private fun phase(
exit: CardioPhaseExitCondition,
targetOverride: CardioTargetSnapshot? = target,
) = CardioGuidedPhase(
phaseId = "phase_test",
kind = CardioPhaseKind.WORK,
target = targetOverride,
exitCondition = exit,
)
@Test
fun fixedDurationUsesElapsedTimeOnly() {
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
}
@Test
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
val p = phase(CardioPhaseExitCondition.EnterTarget)
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
}
@Test
fun enterTargetWithoutTargetNeverCompletes() {
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
}
@Test
fun recoveryThresholdRequiresFreshMeasurement() {
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
}
@Test
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
}
@Test
fun negativeElapsedTimeCannotCompleteAnything() {
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
}
}

View file

@ -0,0 +1,194 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioSessionSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
val exercise =
(source.createExercise(
NewExerciseProfile(
"Cardio sync exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 15)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
assertTrue(
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
val sessionId = source.listSessions().single().sessionId
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: " + command.joinToString(" ") + "\n" + output,
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(
repositoryRoot,
"build/tui/sync-generation-fixture",
).absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val history =
JSONObject(
java.io.File(
captured.stagingDirectory,
"history-v4.json",
).readText(),
)
assertEquals(0, history.getJSONArray("sessions").length())
val cardio =
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-sessions-v1.json",
).readText(),
)
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
assertEquals(1, cardio.getJSONArray("sessions").length())
assertEquals(
"cardio",
cardio
.getJSONArray("sessions")
.getJSONObject(0)
.getString("session_type"),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals(
"training|cardio|15",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
"WHERE s.session_id='$sessionId';",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
ack.toString(),
JSONObject(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "replay-ack.json").absolutePath,
).let { java.io.File(root, "replay-ack.json").readText() },
).toString(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioSessionTypeTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio kind exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(occurrence),
),
),
)
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
db.close()
assertTrue(
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
assertEquals(
SessionType.CARDIO,
repository.listSessions().single().sessionType,
)
val frozen = JSONObject(repository.buildMobileExportV4Json())
assertEquals(0, frozen.getJSONArray("sessions").length())
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
verify.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
verify.close()
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,42 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HeartRateLiveStateTest {
@Test
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
HeartRateLiveState.disconnected()
val observed = mutableListOf<HeartRateLiveSnapshot>()
val subscription = HeartRateLiveState.subscribe { observed += it }
try {
HeartRateLiveState.connecting()
HeartRateLiveState.measurement(
ParsedHeartRateMeasurement(
bpm = 82,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(768),
),
)
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
assertEquals(82, HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
assertEquals(1L, HeartRateLiveState.current().notificationCount)
HeartRateLiveState.stale()
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
HeartRateLiveState.disconnected()
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(5, observed.size)
} finally {
subscription.close()
HeartRateLiveState.disconnected()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HeartRateMeasurementParserTest {
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
val result =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
)
return (result as HeartRateMeasurementParseResult.Parsed).measurement
}
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
) as HeartRateMeasurementParseResult.Invalid
@Test
fun parsesEightBitBpm() {
val value = parsed(0x00, 72)
assertEquals(72, value.bpm)
assertNull(value.sensorContactDetected)
assertNull(value.energyExpended)
assertTrue(value.rrIntervals1024.isEmpty())
}
@Test
fun parsesSixteenBitBpmLittleEndian() {
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
}
@Test
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
assertNull(parsed(0x02, 80).sensorContactDetected)
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
}
@Test
fun parsesEnergyExpended() {
val value = parsed(0x08, 90, 0x34, 0x12)
assertEquals(0x1234, value.energyExpended)
}
@Test
fun parsesOneAndMultipleRawRrIntervals() {
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
assertEquals(
listOf(1024, 1000, 65535),
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
)
}
@Test
fun parsesCombinedKnownFlagsInWireOrder() {
val value =
parsed(
0x1f,
0x96,
0x00,
0x07,
0x00,
0xf0,
0x03,
0x00,
0x04,
)
assertEquals(150, value.bpm)
assertEquals(true, value.sensorContactDetected)
assertEquals(7, value.energyExpended)
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
}
@Test
fun rejectsTruncatedAndIncoherentPayloads() {
assertEquals("missing flags", invalid().reason)
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
}
@Test
fun preservesBoundaryBpmValues() {
assertEquals(0, parsed(0x00, 0).bpm)
assertEquals(255, parsed(0x00, 255).bpm)
}
}

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.SleepDiaryDraft
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
val name = "heart-rate-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val created =
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val started =
repository.startHeartRateCapture(
HeartRateContextKind.SLEEP,
created.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR sensor",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
val sample =
repository.appendHeartRateSample(
captureId = started.captureId,
observedAt = "2026-09-22T22:30:01+02:00",
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals = listOf(
HeartRateRrInterval(0, 1024),
HeartRateRrInterval(1, 1000),
),
) as TrainlogRepository.HeartRateMutationResult.Applied
assertEquals(0L, sample.sequence)
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertTrue(
repository.stopHeartRateCapture(
started.captureId,
"2026-09-23T06:30:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
val root = JSONObject(repository.buildHeartRateV1Json())
assertEquals("trainlog-heart-rate", root.getString("format"))
assertEquals(1, root.getInt("version"))
val capture = root.getJSONArray("captures").getJSONObject(0)
assertEquals(created.entryId, capture.getString("context_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
val stored = capture.getJSONArray("samples").getJSONObject(0)
assertEquals(61, stored.getInt("bpm"))
assertTrue(stored.getBoolean("sensor_contact_detected"))
assertEquals(7, stored.getInt("energy_expended"))
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,32 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateSensorPreferencesTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun selectionIsDurableAndClearable() {
val preferences = HeartRateSensorPreferences(context)
preferences.clear()
assertNull(preferences.selected())
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
assertEquals(
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
HeartRateSensorPreferences(context).selected(),
)
preferences.clear()
assertNull(HeartRateSensorPreferences(context).selected())
}
}

View file

@ -0,0 +1,490 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.SessionSetDraft
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProgramSessionCardioReadinessTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
val android = TrainlogRepository(context, androidName)
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val desktopDb = java.io.File(root, "desktop.sqlite")
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
fun measurement(bpm: Int, rr: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(rr),
)
try {
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val programExerciseAId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
val programExerciseBId = "ex_11111111-1111-4111-8111-111111111111"
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(
JSONObject()
.put("exercise_id", programExerciseAId)
.put("name", "Program exercise fixture A")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
)
.put(
JSONObject()
.put("exercise_id", programExerciseBId)
.put("name", "Program exercise fixture B")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("title", "Tomorrow readiness")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-23T16:00:00Z")
.put("updated_at", "2026-09-23T16:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Session A")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put(
"entry_id",
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
.put("exercise_id", programExerciseAId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 1)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
)
.put(
JSONObject()
.put(
"entry_id",
"pge_22222222-2222-4222-8222-222222222222",
)
.put("exercise_id", programExerciseBId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 60)
.put("target_sets", 1)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 25.0)
.put("notes", JSONObject.NULL),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-23T16:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
android.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
android.startSyncedProgramSession(programId, programSessionId),
)
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
assertEquals(programId, opened.draft.sourceProgramId)
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
assertEquals(2, opened.draft.exercises.size)
val exerciseA = opened.draft.exercises[0]
val exerciseBEntry = opened.draft.exercises[1]
assertEquals(programExerciseAId, exerciseA.exercise.exerciseId)
assertEquals(programExerciseBId, exerciseBEntry.exercise.exerciseId)
assertTrue(exerciseA.entryId != exerciseBEntry.entryId)
assertEquals(
ActiveDraftMutationResult.Saved,
android.saveActiveSessionDraft(
opened.draft.copy(
exercises =
listOf(
exerciseA.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
),
exerciseBEntry.copy(
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
),
),
),
),
)
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(sessionId, edited.draft.sessionId)
assertEquals(sessionStartedAt, edited.draft.startedAt)
assertEquals(programId, edited.draft.sourceProgramId)
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
val preExerciseAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
preExerciseAt,
measurement(91, 675),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val capture = checkNotNull(android.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
assertEquals(sessionId, capture.contextId)
assertEquals(sessionStartedAt, capture.startedAt)
val exerciseAStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseASampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseASampleAt,
measurement(112, 548),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseAEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val betweenExercisesAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
betweenExercisesAt,
measurement(98, 620),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseBStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBStartedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseBSampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseBSampleAt,
measurement(119, 525),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val activeFinalize = android.finalizeActiveSessionDraft()
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
assertTrue(
(activeFinalize as FinalizeActiveDraftResult.Invalid)
.message
.startsWith("ACTIVE_EXERCISE:"),
)
val exerciseBEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBEndedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val finalized = android.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
assertNull(android.activeHeartRateCapture())
val timeline = JSONObject(android.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
val sessionEndedAt = timelineSession.getString("ended_at")
assertEquals(sessionId, timelineSession.getString("session_id"))
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
val timelineExercises = timelineSession.getJSONArray("exercises")
assertEquals(2, timelineExercises.length())
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
assertEquals(
exerciseBEntry.entryId,
timelineExercises.getJSONObject(1).getString("entry_id"),
)
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
val heart = JSONObject(android.buildHeartRateV1Json())
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
assertEquals("session", heartCapture.getString("context_kind"))
assertEquals(sessionId, heartCapture.getString("context_id"))
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
val samples = heartCapture.getJSONArray("samples")
assertEquals(4, samples.length())
val expectedOwnership =
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
val expectedTimes =
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
val expectedRr = listOf(675, 548, 620, 525)
for (index in 0 until samples.length()) {
val sample = samples.getJSONObject(index)
assertEquals(index.toLong(), sample.getLong("sequence"))
assertEquals(expectedTimes[index], sample.getString("observed_at"))
if (expectedOwnership[index] == null) {
assertTrue(sample.isNull("exercise_entry_id"))
} else {
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
}
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(androidName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT source_program_id,source_program_session_id FROM sessions " +
"WHERE session_id=?",
arrayOf(sessionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(programId, cursor.getString(0))
assertEquals(programSessionId, cursor.getString(1))
}
}
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(android)
val captured = service.capture(root, desktopPeer)
val published = service.publish(captured, java.io.File(root, "objects"))
val publishedTimeline =
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
val publishedHeart =
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
val firstAck = java.io.File(root, "desktop-ack-first.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
firstAck.absolutePath,
)
val countsBeforeReplay =
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim()
assertEquals("2|4|4", countsBeforeReplay)
val replayAck = java.io.File(root, "desktop-ack-replay.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
replayAck.absolutePath,
)
assertEquals(
countsBeforeReplay,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(firstAck.readBytes()),
)
assertEquals(
0,
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
)
assertEquals(
0,
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
val desktopFacts =
JSONObject(
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT json_object(" +
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
"JOIN program_session_executions x ON x.session_id=s.session_id " +
"WHERE s.session_id='$sessionId' AND x.state='completed';",
).trim(),
)
assertEquals(programId, desktopFacts.getString("program_id"))
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
assertEquals(
listOf(
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT context_kind,context_id,started_at,ended_at " +
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
).trim(),
)
assertEquals(
listOf(
"0|$preExerciseAt|",
"1|$exerciseASampleAt|${exerciseA.entryId}",
"2|$betweenExercisesAt|",
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
"FROM heart_rate_samples " +
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
expectedRr,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT value_1024 FROM heart_rate_rr_intervals " +
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
)
} finally {
android.close()
context.deleteDatabase(androidName)
root.deleteRecursively()
}
}
}

View file

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

View file

@ -0,0 +1,458 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionTimelineRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun startedOccurrenceCannotBeRemovedOrSilentlyClosedBySessionFinalization() {
val name = "timeline-guard-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Timeline guarded exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertEquals(
ActiveDraftMutationResult.Saved,
repository.startActiveSessionDraft(),
)
val opened =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
)
val activeDraft = opened.draft.copy(exercises = listOf(occurrence))
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(activeDraft),
)
assertTrue(
repository.startActiveSessionExercise(occurrence.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val removed = repository.saveActiveSessionDraft(activeDraft.copy(exercises = emptyList()))
assertTrue(removed is ActiveDraftMutationResult.Error)
val finalize = repository.finalizeActiveSessionDraft()
assertTrue(finalize is FinalizeActiveDraftResult.Invalid)
assertTrue(
(finalize as FinalizeActiveDraftResult.Invalid)
.message
.startsWith("ACTIVE_EXERCISE:"),
)
assertTrue(
repository.finishActiveSessionExercise(occurrence.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
repository.finalizeActiveSessionDraft() is
FinalizeActiveDraftResult.Saved,
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun trainingTimelineCorrelatesMeasuredSamplesWithoutInventingRestOwner() {
val name = "timeline-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
fun exercise(label: String) =
(repository.createExercise(
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise
val first = exercise("Timeline A")
val second = exercise("Timeline B")
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val firstEntry = SessionExerciseDraft(
exercise = first,
sets = listOf(SessionSetDraft(reps = 10)),
)
val secondEntry = SessionExerciseDraft(
exercise = second,
sets = listOf(SessionSetDraft(reps = 10)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
opened.draft.copy(exercises = listOf(firstEntry, secondEntry)),
),
)
val startA = OffsetDateTime.now().toString()
val startedA = repository.startActiveSessionExercise(firstEntry.entryId, startA)
assertTrue(startedA is TrainlogRepository.SessionExerciseTimingResult.Started)
val conflict = repository.startActiveSessionExercise(secondEntry.entryId)
assertEquals(
firstEntry.entryId,
(conflict as TrainlogRepository.SessionExerciseTimingResult.ActiveConflict).activeEntryId,
)
val measurement = ParsedHeartRateMeasurement(
bpm = 101,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(620),
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR", OffsetDateTime.now().toString(), measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.finishActiveSessionExercise(firstEntry.entryId)
is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 88),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.startActiveSessionExercise(secondEntry.entryId)
is TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 109),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.finishActiveSessionExercise(secondEntry.entryId)
is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val finalized = repository.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
val session = timeline.getJSONArray("sessions").getJSONObject(0)
assertEquals(2, session.getJSONArray("exercises").length())
assertEquals(firstEntry.entryId, session.getJSONArray("exercises").getJSONObject(0).getString("entry_id"))
assertEquals(secondEntry.entryId, session.getJSONArray("exercises").getJSONObject(1).getString("entry_id"))
val heart = JSONObject(repository.buildHeartRateV1Json())
val samples = heart.getJSONArray("captures").getJSONObject(0).getJSONArray("samples")
assertEquals(3, samples.length())
assertEquals(firstEntry.entryId, samples.getJSONObject(0).getString("exercise_entry_id"))
assertTrue(samples.getJSONObject(1).isNull("exercise_entry_id"))
assertEquals(secondEntry.entryId, samples.getJSONObject(2).getString("exercise_entry_id"))
assertEquals(101, samples.getJSONObject(0).getInt("bpm"))
assertEquals(88, samples.getJSONObject(1).getInt("bpm"))
assertEquals(109, samples.getJSONObject(2).getInt("bpm"))
assertNull(repository.activeHeartRateCapture())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
val name = "program-session-finish-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val programId = "pg_11111111-1111-4111-8111-111111111111"
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
val rowId = "ex_44444444-4444-4444-8444-444444444444"
val pressId = "ex_55555555-5555-4555-8555-555555555555"
val curlId = "ex_66666666-6666-4666-8666-666666666666"
fun catalogExercise(
exerciseId: String,
exerciseName: String,
recordingMode: String,
trackingMode: String,
) =
JSONObject()
.put("exercise_id", exerciseId)
.put("name", exerciseName)
.put("recording_mode", recordingMode)
.put("tracking_mode", trackingMode)
.put("data_fields", 0)
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
fun occurrence(
entryId: String,
exerciseId: String,
targetSets: Int?,
targetReps: Int?,
targetDuration: Int?,
targetWeight: Double?,
restSeconds: Int,
) =
JSONObject()
.put("entry_id", entryId)
.put("exercise_id", exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", if (targetWeight == null) "none" else "external")
.put("rest_seconds", restSeconds)
.put("target_sets", targetSets ?: JSONObject.NULL)
.put("target_reps", targetReps ?: JSONObject.NULL)
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
.put("notes", JSONObject.NULL)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
.put("title", "Session finalization regression")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-24T08:00:00Z")
.put("updated_at", "2026-09-24T08:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Workout")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
occurrence(
"pge_88888888-8888-4888-8888-888888888888",
warmupId,
null,
null,
480,
null,
0,
),
)
.put(
occurrence(
"pge_99999999-9999-4999-8999-999999999999",
rowId,
3,
10,
null,
39.0,
90,
),
)
.put(
occurrence(
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
pressId,
3,
10,
null,
32.0,
90,
),
)
.put(
occurrence(
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
curlId,
2,
10,
null,
23.0,
60,
),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-24T08:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
repository.startSyncedProgramSession(programId, programSessionId),
)
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
val warmup = opened.draft.exercises[0]
val row = opened.draft.exercises[1]
val press = opened.draft.exercises[2]
val curl = opened.draft.exercises[3]
val performed =
opened.draft.copy(
exercises =
listOf(
warmup,
row.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
),
press.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
),
curl.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
),
),
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
val warmupStart = sessionStart.plusSeconds(10)
val warmupEnd = warmupStart.plusSeconds(433)
assertTrue(
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
/* Recreate the exact v49 persisted state: its timeline was closed,
* but the target-only continuous occurrence had no performed row. */
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
db.execSQL(
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
arrayOf(warmup.entryId),
)
}
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
listOf(row, press, curl).forEachIndexed { index, exercise ->
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
val endedAt = startedAt.plusSeconds(60)
assertTrue(
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
}
assertTrue(
repository.saveDraftExerciseFeedback(
curl.entryId,
"Valid effort feedback",
warmupEnd.plusSeconds(400).toString(),
) is SaveFeedbackResult.Saved,
)
val measurement =
ParsedHeartRateMeasurement(
bpm = 107,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(620),
)
val finalizationAt = warmupEnd.plusSeconds(500)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
finalizationAt.minusSeconds(1).toString(),
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val lateSampleAt = finalizationAt.plusSeconds(10)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lateSampleAt.toString(),
measurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
assertEquals(
sessionId,
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
)
val detail = checkNotNull(repository.getSessionDetail(sessionId))
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
assertEquals(4, detail.exercises.size)
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
assertEquals(2, detail.exercises[3].plan?.sets)
assertEquals(3, detail.exercises[3].sets.size)
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
assertEquals(1, detail.exercises[3].feedback.size)
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
assertEquals(4, timelineSession.getJSONArray("exercises").length())
val heart = JSONObject(repository.buildHeartRateV1Json())
val capture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(finalizationAt.toString(), capture.getString("ended_at"))
assertEquals(1, capture.getJSONArray("samples").length())
assertEquals(1, capture.getJSONArray("samples").getJSONObject(0)
.getJSONArray("rr_intervals_1024").length())
assertNull(repository.activeHeartRateCapture())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,209 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionTimelineSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
fun exercise(label: String) =
(source.createExercise(
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise
val a = SessionExerciseDraft(
exercise = exercise("Sync timeline A"),
sets = listOf(SessionSetDraft(reps = 8)),
)
val b = SessionExerciseDraft(
exercise = exercise("Sync timeline B"),
sets = listOf(SessionSetDraft(reps = 9)),
)
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
)
assertTrue(
source.startActiveSessionExercise(a.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.finishActiveSessionExercise(a.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base.copy(bpm = 84),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.startActiveSessionExercise(b.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base.copy(bpm = 105),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.finishActiveSessionExercise(b.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: " + command.joinToString(" ") + "\n" + output,
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val timelineJson =
JSONObject(
java.io.File(
captured.stagingDirectory,
"session-timeline-v1.json",
).readText(),
)
assertEquals(1, timelineJson.getJSONArray("sessions").length())
assertEquals(
2,
timelineJson
.getJSONArray("sessions")
.getJSONObject(0)
.getJSONArray("exercises")
.length(),
)
val heartJson =
JSONObject(
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
)
assertEquals(
3,
heartJson
.getJSONArray("captures")
.getJSONObject(0)
.getJSONArray("samples")
.length(),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
assertEquals(
"2|3|3",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals);",
).trim(),
)
assertEquals(
0,
JSONObject(source.buildSessionTimelineV1Json())
.getJSONArray("sessions")
.length(),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.length(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -1,6 +1,8 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent
@ -11,6 +13,7 @@ import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@ -73,6 +76,141 @@ class SleepDiaryRepositoryTest {
} finally { repository.close(); context.deleteDatabase(name) }
}
@Test
fun persistedSleepRevisionPayloadIsAppendOnly() {
val name = "sleep-immutable-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val saved = try {
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
} finally {
repository.close()
}
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"DELETE FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertEquals(
"B",
raw.rawQuery(
"SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
},
)
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() {
val name = "sleep-revision-chain-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val identities = mutableListOf<String>()
val expectedParents = mutableMapOf<String, String?>()
val expectedNotes = mutableMapOf<String, String>()
try {
val created = repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "revision-root",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
identities += created.revisionId
expectedParents[created.revisionId] = null
expectedNotes[created.revisionId] = "revision-root"
repeat(64) { index ->
val current = repository.listSleepDiary().single()
val note = "revision-$index"
val hour = 8 + index / 60
val minute = index % 60
val saved = repository.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute),
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = current.dayForm,
treatmentAndNotes = note,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
expectedParents[saved.revisionId] = current.revisionId
expectedNotes[saved.revisionId] = note
identities += saved.revisionId
}
} finally {
repository.close()
}
assertEquals(65, identities.toSet().size)
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
)
try {
identities.forEach { revisionId ->
raw.rawQuery(
"SELECT parent_revision_id,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
val parent = if (cursor.isNull(0)) null else cursor.getString(0)
assertEquals(expectedParents[revisionId], parent)
assertEquals(expectedNotes[revisionId], cursor.getString(1))
}
}
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun ancestryAllowsMultiHopRemoteAdvance() {
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
@ -188,6 +326,15 @@ class SleepDiaryRepositoryTest {
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
val reusedAncestor = org.json.JSONObject(firstSnapshot)
reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M")
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
),
destination.applySleepDiaryV1Json(reusedAncestor.toString()),
)
} finally {
source.close()
destination.close()

View file

@ -0,0 +1,287 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SleepQuickCaptureTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun pendingMedicationSurvivesReopenAndBedTimeKeepsEntryIdentity() {
val name = "sleep-reopen-" + UUID.randomUUID()
var repository = TrainlogRepository(context, name)
try {
val medication =
repository.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Test medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "",
note = "",
active = true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val pending =
repository.quickSleepMedication(
medication.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
repository.close()
repository = TrainlogRepository(context, name)
val bed =
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(pending.receipt.entryId, bed.receipt.entryId)
assertEquals(2, repository.listSleepDiary().single().intakes.single().quantity)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() {
val name = "sleep-quick-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
val medicationCreated =
repository.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Test medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "",
note = "",
active = true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val medication =
repository.listSleepMedications(false).single {
it.medicationId == medicationCreated.entryId
}
val preBed = repository.quickSleepMedication(
medication.medicationId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
assertNull(repository.activeHeartRateCapture())
val pending = repository.listSleepDiary().single()
assertTrue(pending.events.none { it.type == SleepEventType.BED_TIME })
assertEquals(2, pending.intakes.single().quantity)
val preBedExport = JSONObject(repository.buildSleepDiaryV1Json())
val exportedPending = preBedExport.getJSONArray("entries").getJSONObject(0)
assertEquals(preBed.receipt.entryId, exportedPending.getString("entry_id"))
assertEquals(preBed.receipt.appliedRevisionId, exportedPending.getString("revision_id"))
assertEquals(0, exportedPending.getJSONArray("events").length())
assertEquals(1, exportedPending.getJSONArray("intakes").length())
val bed =
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate)
val measurement =
ParsedHeartRateMeasurement(
bpm = 62,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1000),
)
assertTrue(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-23T22:30:01+02:00",
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
assertTrue(
repository.quickSleepMedication(
medication.medicationId,
"2026-09-23T22:35:00+02:00",
) is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertTrue(
repository.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertTrue(
repository.quickSleepWake("2026-09-24T04:42:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
val beforeGetUp = repository.listSleepDiary().single()
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
assertEquals(
2,
beforeGetUp.events.count { it.type == SleepEventType.LONG_AWAKE },
)
assertEquals(
listOf(
"2026-09-24T02:17:00+02:00" to "2026-09-24T02:47:00+02:00",
"2026-09-24T04:42:00+02:00" to "2026-09-24T05:12:00+02:00",
),
beforeGetUp.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
assertTrue(beforeGetUp.events.none { it.type == SleepEventType.NIGHT_GET_UP })
assertEquals(2, beforeGetUp.intakes.size)
assertEquals(2, beforeGetUp.intakes.first().quantity)
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
assertTrue(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-24T04:43:00+02:00",
measurement.copy(bpm = 65),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
assertNull(
repository.recordLiveHeartRateForActiveSleep(
"CYCPLUS H2",
"2026-09-24T06:32:00+02:00",
measurement.copy(bpm = 70),
),
)
val finalEntry = repository.listSleepDiary().single()
assertEquals(
1,
finalEntry.events.count { it.type == SleepEventType.FINAL_GET_UP },
)
val exported = JSONObject(repository.buildHeartRateV1Json())
val capture = exported.getJSONArray("captures").getJSONObject(0)
assertEquals(captureId, capture.getString("capture_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals(finalEntry.entryId, capture.getString("context_id"))
assertEquals(62, capture.getJSONArray("samples").getJSONObject(0).getInt("bpm"))
assertEquals("2026-09-23T22:30:00+02:00", capture.getString("started_at"))
assertEquals("2026-09-24T06:31:00+02:00", capture.getString("ended_at"))
assertEquals(2, capture.getJSONArray("samples").length())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun undoIsGuardedByTheExactAppliedRevision() {
val name = "sleep-undo-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
val firstWake =
repository.quickSleepWake("2026-09-24T02:00:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
val secondWake =
repository.quickSleepWake("2026-09-24T03:00:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertTrue(
repository.undoSleepQuickAction(firstWake.receipt)
is TrainlogRepository.SleepQuickActionResult.Conflict,
)
assertTrue(
repository.undoSleepQuickAction(
secondWake.receipt,
"2026-09-24T03:00:05+02:00",
) is TrainlogRepository.SleepQuickActionResult.Applied,
)
val night = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T02:00:00+02:00"),
night.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun repeatedWakeExtendsOneWindowAndFinalGetUpClampsIt() {
val name = "sleep-wake-window-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:05:00+02:00")
repository.quickSleepWake("2026-09-24T04:11:00+02:00")
repository.quickSleepWake("2026-09-24T04:14:00+02:00")
val extended = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:44:00+02:00"),
extended.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
repository.quickSleepFinalGetUp("2026-09-24T04:14:30+02:00")
val completed = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:14:30+02:00"),
completed.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
val name = "sleep-no-sensor-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
assertNull(repository.activeHeartRateCapture())
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -296,7 +296,7 @@ class StatisticsRepositoryTest {
}
private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") {
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type))
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
}
private fun occurrence(

View file

@ -56,6 +56,7 @@ class SyncDeploymentConversationTest {
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, databaseName)
var localWorker: Process? = null
var lostDesktopAckGeneration: String? = null
try {
transport.mkdirs()
// A previous interrupted exchange leaves durable coordination
@ -180,6 +181,7 @@ class SyncDeploymentConversationTest {
"full_generation_trigger_published",
"generation_request_observed",
"archive_ack_observed",
"android_archive_ack_published",
"generation_capture_started",
"generation_captured",
"generation_objects_published",
@ -215,6 +217,71 @@ class SyncDeploymentConversationTest {
}
}
}
if (suppliedTransport == null && it == 11) {
/* Reproduce the inverse deployed failure: Android has
* durably consumed the newest desktop generation, but its
* ACK was lost before the desktop producer committed it.
* The next conversation must recover that exact Android
* evidence before capturing another desktop generation. */
SQLiteDatabase.openDatabase(
desktop.absolutePath,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { database ->
val generation =
database.rawQuery(
"SELECT generation_id FROM sync_generations " +
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
database.delete(
"sync_acknowledgements",
"generation_id=?",
arrayOf(generation),
)
database.execSQL(
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
"acknowledged_at=NULL WHERE generation_id=?",
arrayOf(generation),
)
database.rawQuery(
"SELECT status FROM sync_generations WHERE generation_id=?",
arrayOf(generation),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("waiting_acknowledgement", cursor.getString(0))
}
lostDesktopAckGeneration = generation
}
}
if (suppliedTransport == null && it == 12) {
val recovered = checkNotNull(lostDesktopAckGeneration)
SQLiteDatabase.openDatabase(
desktop.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { database ->
database.rawQuery(
"SELECT status FROM sync_generations WHERE generation_id=?",
arrayOf(recovered),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("acknowledged", cursor.getString(0))
}
database.rawQuery(
"SELECT parent_generation_id,status FROM sync_generations " +
"ORDER BY generated_at DESC LIMIT 1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(recovered, cursor.getString(0))
assertEquals("acknowledged", cursor.getString(1))
}
}
}
localWorker = null
}
repository.close()

View file

@ -3,11 +3,15 @@ package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepMedication
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.nio.file.Files
@ -27,6 +31,299 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() {
val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile()
val desktopDb = java.io.File(root, "desktop.sqlite")
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
var source = TrainlogRepository(context, sourceName)
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
try {
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
)
)!!
.groupValues[1]
val medicationCreated =
source.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Synthetic sleep-only medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "tablet",
note = "",
active = true,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val pendingRoot =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-23",
nightEndDate = "2026-09-24",
createdAt = "2026-09-23T22:00:00+02:00",
updatedAt = "2026-09-23T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
intakes = emptyList(),
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val preBed =
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:21:00+02:00",
) as TrainlogRepository.SleepQuickActionResult.Applied
source.inSyncGenerationTransaction { db ->
assertEquals(
2,
db.rawQuery(
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
arrayOf(preBed.receipt.entryId),
).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
},
)
}
source.close()
source = TrainlogRepository(context, sourceName)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val bed =
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals1024 = listOf(1000),
)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-23T22:30:01+02:00",
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-24T02:17:01+02:00",
firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)),
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(source.listSessions().isEmpty())
val night = source.listSleepDiary().single()
val capture =
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.getJSONObject(0)
assertEquals(night.entryId, capture.getString("context_id"))
assertEquals(2, capture.getJSONArray("samples").length())
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val diaryArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText())
val heartArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText())
val historyArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText())
assertEquals(0, historyArtifact.getJSONArray("sessions").length())
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
assertEquals(night.entryId, publishedNight.getString("entry_id"))
assertEquals(2, publishedNight.getJSONArray("intakes").length())
assertEquals(
listOf(1, 2),
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
}.sorted(),
)
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
val published = service.publish(captured, java.io.File(root, "android-objects"))
val ack = java.io.File(root, "sleep-only-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ack.absolutePath,
)
val ackDocument = JSONObject(ack.readText())
assertEquals(
"desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}",
"consumed",
ackDocument.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes()))
assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes()))
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "sleep-only-replay-ack.json").absolutePath,
)
assertEquals(
// The desktop fixture owns one reserved calibration session;
// the empty Android history must not add a workout.
"1|2|2,1|2|2|1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM sessions);",
)
.trim(),
)
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
staleEntry.put("revision_id", pendingRoot.revisionId)
staleEntry.put("parent_revision_id", JSONObject.NULL)
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
staleEntry.put("events", org.json.JSONArray())
staleEntry.put("intakes", org.json.JSONArray())
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val revised =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = night.entryId,
expectedRevision = night.revisionId,
nightStartDate = night.nightStartDate,
nightEndDate = night.nightEndDate,
createdAt = night.createdAt,
updatedAt = "2026-09-24T07:00:00+02:00",
sleepQuality = night.sleepQuality,
wakeQuality = night.wakeQuality,
dayForm = night.dayForm,
treatmentAndNotes = "Sleep-only revision two",
events = night.events,
intakes = night.intakes,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revised.entryId,
revised.revisionId,
"2026-09-24T07:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved
)
val second = service.capture(root, desktopPeer)
val secondPublished =
service.publish(second, java.io.File(root, "android-objects-second"))
val secondAck = java.io.File(root, "sleep-only-second-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
secondPublished.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
secondAck.absolutePath,
)
assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes()))
assertEquals(
revised.revisionId,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';",
)
.trim(),
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
assertTrue(source.listSessions().isEmpty())
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
@Test
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
val repositoryRoot =
@ -319,6 +616,13 @@ class SyncGenerationServiceTest {
destinationService.peerId(),
runId,
)
val retryRun = "sy_${UUID.randomUUID()}"
val crossRunResume =
SyncGenerationCoordinator(source)
.resumableGeneration(retryRun, destinationService.peerId())
assertEquals(outbound.generationId, crossRunResume?.generationId)
assertEquals(retryRun, crossRunResume?.runId)
assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256)
val outboundDirectory =
sourceService.publish(outbound, java.io.File(root, "source-objects"))
sourceService.acceptAcknowledgement(
@ -350,6 +654,74 @@ class SyncGenerationServiceTest {
}
}
@Test
fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() {
val generationId = "gen_${UUID.randomUUID()}"
val originalRunId = "sy_${UUID.randomUUID()}"
val retryRunId = "sy_${UUID.randomUUID()}"
val digest = "a".repeat(64)
val captured =
CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused"))
val acknowledgement =
JSONObject()
.put("run_id", originalRunId)
.put("generation_id", generationId)
.put("manifest_sha256", digest)
assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
acknowledgement.put("manifest_sha256", "b".repeat(64))
assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
}
@Test
fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() {
val name = "generation-superseded-capacity-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile()
val repository = TrainlogRepository(context, name)
try {
val service = SyncGenerationService(repository)
val peer = "peer_11111111-1111-4111-8111-111111111111"
val branches = (0 until 4).map { service.capture(root, peer) }
val acknowledged = branches.last()
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?",
arrayOf("gen_22222222-2222-4222-8222-222222222222", peer),
)
db.execSQL(
"UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " +
"WHERE generation_id=?",
arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId),
)
db.execSQL(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
"ack_${UUID.randomUUID()}",
acknowledged.generationId,
acknowledged.runId,
service.peerId(),
peer,
acknowledged.manifestSha256,
"consumed",
"sqlite-commit-full",
"2026-09-24T07:00:00+02:00",
"",
"a".repeat(64),
),
)
}
// The three sibling branches remain durable, but the ledger-backed
// acknowledged sibling proves that none can be the causal tip.
assertEquals(3, branches.dropLast(1).size)
assertTrue(service.capture(root, peer).generationId.startsWith("gen_"))
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
@Test
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
@ -506,7 +878,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(26, it.getInt(0))
assertEquals(33, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
@ -855,7 +1227,73 @@ class SyncGenerationServiceTest {
)
)!!
.groupValues[1]
val sleepEntry =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
sleepEntry.entryId,
sleepEntry.revisionId,
"2026-09-23T07:00:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val heartCapture =
source.startHeartRateCapture(
HeartRateContextKind.SLEEP,
sleepEntry.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertTrue(
source.appendHeartRateSample(
heartCapture.captureId,
"2026-09-22T22:30:01+02:00",
61,
true,
7,
listOf(HeartRateRrInterval(0, 1024)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.stopHeartRateCapture(
heartCapture.captureId,
"2026-09-22T22:31:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val androidToDesktop = sourceService.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
.readText(),
)
.getJSONArray("captures")
.length(),
)
val heartRateCorrections =
JSONObject(
java.io.File(
androidToDesktop.stagingDirectory,
"heart-rate-corrections-v1.json",
).readText(),
)
assertEquals(
"trainlog-heart-rate-corrections",
heartRateCorrections.getString("format"),
)
assertEquals(0, heartRateCorrections.getJSONArray("corrections").length())
val androidPublished =
sourceService.publish(
androidToDesktop,
@ -872,10 +1310,35 @@ class SyncGenerationServiceTest {
"--ack-output",
androidAckFile.absolutePath,
)
val androidAck = JSONObject(androidAckFile.readText())
assertEquals(
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
"consumed",
androidAck.getString("result"),
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertEquals(
"1|1|1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT (SELECT count(*) FROM heart_rate_captures) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals);",
)
.trim(),
)
val androidSessionId = source.listSessions().single().sessionId
assertEquals(
"1",
@ -887,6 +1350,69 @@ class SyncGenerationServiceTest {
.trim(),
)
/* A second Sleep-only mutation must advance the same diary entry
* after the first correlated ACK. No new workout is created, and
* replaying either generation remains strictly idempotent. */
val revisedSleep =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = sleepEntry.entryId,
expectedRevision = sleepEntry.revisionId,
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-23T08:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "second sleep-only revision",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revisedSleep.entryId,
revisedSleep.revisionId,
"2026-09-23T08:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val secondSleepGeneration = sourceService.capture(root, desktopPeer)
val secondSleepPublished =
sourceService.publish(
secondSleepGeneration,
java.io.File(root, "android-desktop-objects"),
)
val secondAckFile = java.io.File(root, "android-desktop-second-ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
secondSleepPublished.absolutePath,
"--database",
desktopConsumerDb.absolutePath,
"--ack-output",
secondAckFile.absolutePath,
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
)
assertEquals(
"1|${revisedSleep.revisionId}|1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT count(*) || '|' || current_revision_id || '|' || " +
"(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " +
"FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';",
)
.trim(),
)
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,

View file

@ -13,6 +13,8 @@ import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
@ -56,6 +58,126 @@ class TrainlogRepositoryDraftTest {
databaseName = "draft-test-${UUID.randomUUID()}.db"
}
@Test
fun delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively() {
val repo = openRepository()
val exercise = createExercise(repo, "Récupération cardio", RecordingMode.SETS, TrackingMode.REPS)
val sessionId = "se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val entryId = "sxe_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val startedAt = "2026-09-24T10:00:00+02:00"
val cutoff = "2026-09-24T11:16:00+02:00"
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
ActiveSessionDraft(
sessionId = sessionId,
startedAt = startedAt,
exercises = listOf(
SessionExerciseDraft(
entryId = entryId,
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 8, weightKg = 20.0)),
),
),
),
),
)
val capture = repo.startHeartRateCapture(
HeartRateContextKind.SESSION,
sessionId,
startedAt,
"Synthetic",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertTrue(repo.startActiveSessionExercise(
entryId, "2026-09-24T10:30:00+02:00",
) is TrainlogRepository.SessionExerciseTimingResult.Started)
assertTrue(repo.finishActiveSessionExercise(
entryId, "2026-09-24T11:15:00+02:00",
) is TrainlogRepository.SessionExerciseTimingResult.Finished)
listOf(
"2026-09-24T11:15:59.999999+02:00" to 100,
cutoff to 101,
"2026-09-24T11:16:00.000001+02:00" to 40,
).forEachIndexed { index, (observedAt, bpm) ->
assertTrue(
repo.appendHeartRateSample(
capture.captureId,
observedAt,
bpm,
rrIntervals = listOf(HeartRateRrInterval(0, 900 + index)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
}
assertEquals(
FinalizeActiveDraftResult.Saved(sessionId),
repo.finalizeActiveSessionDraft(cutoff),
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(cutoff, db.rawQuery(
"SELECT ended_at FROM heart_rate_captures WHERE capture_id=?",
arrayOf(capture.captureId),
).use { it.moveToFirst(); it.getString(0) })
assertEquals(
listOf("2026-09-24T11:15:59.999999+02:00", cutoff),
db.rawQuery(
"SELECT observed_at FROM heart_rate_samples WHERE capture_id=? " +
"ORDER BY sequence",
arrayOf(capture.captureId),
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } },
)
assertEquals(2, db.rawQuery(
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?",
arrayOf(capture.captureId),
).use { it.moveToFirst(); it.getInt(0) })
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}
}
@Test
fun heartRateCorrectionsRemainOutsideFrozenCausalDeletionV1() {
val repo = openRepository()
repo.buildCausalDeletionExportV1Json()
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL(
"INSERT INTO sync_causal_operations(" +
"operation_id,target_kind,target_id,creator_id,predecessor_revision_id," +
"created_at,payload_sha256,publication_context) VALUES(?,?,?,?,?,?,?,NULL)",
arrayOf(
"hrx_11111111-1111-4111-8111-111111111111",
"heart_rate_tail",
"hrc_22222222-2222-4222-8222-222222222222|" +
"2026-09-24T11:16:00+02:00",
"peer_test",
"lv_test",
"2026-09-24T12:00:00+02:00",
"0".repeat(64),
),
)
}
assertEquals(
0,
JSONObject(repo.buildCausalDeletionExportV1Json())
.getJSONArray("operations")
.length(),
)
val correction = JSONObject(repo.buildHeartRateCorrectionsV1Json())
assertEquals("trainlog-heart-rate-corrections", correction.getString("format"))
assertEquals(
"hrx_11111111-1111-4111-8111-111111111111",
correction.getJSONArray("corrections").getJSONObject(0).getString("operation_id"),
)
}
@After
fun tearDown() {
repository?.close()
@ -2260,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(33, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(33, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -4,12 +4,12 @@
"columns": 12,
"max_y": 200,
"tiles": [
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
]
}

View file

@ -1,6 +1,6 @@
# Android application
## Sleep Diary V1
## Sleep Diary V1 and active V2 companion
Android schema v26 additively mirrors the Sleep Diary revision/event model for
local-first capture. The unreleased schema-v26 contract also includes the
@ -18,6 +18,56 @@ The screen resumes the most recent entry and distinguishes draft, ready,
synchronized and modified tips. “Validate day” marks the current revision for
the existing global synchronization action and never makes it immutable.
Android schema v32 additively stores each immutable medication-intake
`quantity` as an integer in `1..99`, with `1` backfilled for historical rows.
It also adds a separate quick-publication marker for the exact current
revision. Quick publication makes a current fact eligible for generation; it
does not validate a day and does not change the existing validation/ACK
lifecycle.
Android schema v33 enforces that same immutable identity boundary inside
SQLite. Eight append-only triggers reject update or deletion of Sleep diary
revisions, events, medication intakes, and medication revisions. Repository
imports compare a known ancestor's complete canonical payload before the stale
ancestor shortcut, so a reused ID with a different parent or content cannot be
hidden below a later local tip. All user edits continue to create a fresh
UUIDv4 revision and preserve the previous payload and causal parent.
Medication-intake writes name all columns explicitly, so the additive
`quantity` column cannot reorder note or creation-time values in upgraded
schema-v33 databases.
The quick Sleep capture derives one night identity from the existing
18:00-to-18:00 date rule. A medication action can create that pending entry
before `BED_TIME`, later medication actions reuse it, and Couché appends
`BED_TIME` to the same stable `entry_id`. Réveil and Levé remain unavailable
until bedtime. The compact quantity control defaults to one and resets after a
successful intake; the per-unit dose and unit remain independent structured
facts. Pending entries persist through restart and can synchronize without
inventing bedtime.
The persistence regression begins from an empty pending revision, records two
pre-bed intakes (one with quantity two), and reopens the repository after
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
snapshots through generation, desktop import/ACK, exact replay, rejection of a
stale intake-free ancestor and a later current revision. This is the executable
contract for lifecycle and synchronization retention; it does not infer facts
that are absent from a device database.
The one-tap Réveil action records `long_awake` rather than a point
`night_get_up`: its factual start is the tap timestamp and its policy end is 30
minutes later. Another tap inside that window extends the same interval to 30
minutes after the latest tap; Levé clamps an overlapping interval to the exact
final-get-up timestamp. This explicit default requires no Rendormi interaction,
remains revisioned/undoable, and is not derived from heart-rate measurements.
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
no capture. After Couché, the first fresh real measurement creates or resumes
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
factual bedtime and each measurement retaining its observed timestamp, BPM and
raw RR values. Réveil neither stops nor splits it; Levé closes it at the exact
final-get-up timestamp. No sensor measurement means no fabricated capture or
sample.
## Background USB and private Drive synchronization
SyncScreen and the explicitly enabled background service invoke one
@ -639,7 +689,24 @@ resume action after relaunch.
Final save validates the durable draft, inserts the completed session and actual
values, and removes the draft in one SQLite transaction. Failure rolls back and
retains the draft for retry; repeated completion does not create duplicates.
The existing completed-session save-time timestamp behavior is unchanged.
Planned targets remain prescriptions: finalization accepts fewer or more valid
performed sets and actual repetitions or loads that differ from their targets.
Finishing a target-only continuous occurrence with no supplemental fields
materializes its performed duration from that occurrence's already persisted
timeline start/end interval. This also repairs a preserved draft whose interval
was closed by an older build without creating the performed row. Existing
performed values are never overwritten, and continuous occurrences that require
speed or distance still require those measured values explicitly.
Ordinary completion uses the save-time timestamp. A private recovery build may
embed one exact session identity and one explicit offset-aware factual end time;
only that matching draft uses the supplied time. The repository rejects an end
before the session start or latest exercise marker. When heart-rate samples
arrived after a failed factual session end, delayed finalization keeps samples
at or before that inclusive end and transactionally deletes the strict tail.
RR children follow their BPM sample through the declared foreign-key cascade,
and the stopped capture uses the exact factual end. Retained sample identities
and values are never retimed or renumbered.
PC catalog reconciliation preserves draft references through catalog row
ownership. If an editing selection no longer resolves, only the selection is

View file

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
<title id="title">Trainlog technology badges</title>
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
<style>
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
</style>
<g>
<rect x="0" y="4" width="172" height="44" rx="12" fill="#1e1e2e" stroke="#b4befe" stroke-width="2"/>
<circle cx="24" cy="26" r="6" fill="#b4befe"/>
<text class="label" x="100" y="27">LOCAL-FIRST</text>
</g>
<g>
<rect x="184" y="4" width="116" height="44" rx="12" fill="#1e1e2e" stroke="#89b4fa" stroke-width="2"/>
<circle cx="208" cy="26" r="6" fill="#89b4fa"/>
<text class="label" x="258" y="27">C17</text>
</g>
<g>
<rect x="312" y="4" width="160" height="44" rx="12" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
<circle cx="336" cy="26" r="6" fill="#a6e3a1"/>
<text class="label" x="405" y="27">ANDROID</text>
</g>
<g>
<rect x="484" y="4" width="176" height="44" rx="12" fill="#1e1e2e" stroke="#74c7ec" stroke-width="2"/>
<circle cx="508" cy="26" r="6" fill="#74c7ec"/>
<text class="label" x="584" y="27">LOCAL WEB</text>
</g>
<g>
<rect x="672" y="4" width="132" height="44" rx="12" fill="#1e1e2e" stroke="#cba6f7" stroke-width="2"/>
<circle cx="696" cy="26" r="6" fill="#cba6f7"/>
<text class="label" x="753" y="27">GPLv3</text>
</g>
<g>
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
<circle cx="840" cy="26" r="6" fill="#fab387"/>
<text class="label" x="896" y="27">v0.1.6</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="480" viewBox="0 0 1440 480" role="img" aria-labelledby="title desc">
<title id="title">Trainlog</title>
<desc id="desc">A Catppuccin Mocha banner for a local-first training data system.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#11111b"/>
<stop offset="0.58" stop-color="#181825"/>
<stop offset="1" stop-color="#1e1e2e"/>
</linearGradient>
<linearGradient id="lavender" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b4befe"/>
<stop offset="1" stop-color="#cba6f7"/>
</linearGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0H0V40" fill="none" stroke="#45475a" stroke-width="1" opacity="0.22"/>
</pattern>
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="7" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="1440" height="480" rx="28" fill="url(#bg)"/>
<rect width="1440" height="480" rx="28" fill="url(#grid)"/>
<path d="M0 25Q0 0 25 0H1415Q1440 0 1440 25" fill="none" stroke="#b4befe" stroke-width="4"/>
<g transform="translate(72 64)">
<circle cx="12" cy="12" r="7" fill="#f38ba8"/>
<circle cx="36" cy="12" r="7" fill="#f9e2af"/>
<circle cx="60" cy="12" r="7" fill="#a6e3a1"/>
<text x="90" y="19" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">~/trainlog</text>
<text x="0" y="164" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="92" font-weight="700" letter-spacing="10">TRAINLOG</text>
<rect x="2" y="190" width="610" height="5" rx="2.5" fill="url(#lavender)"/>
<text x="0" y="243" fill="#b4befe" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="27" letter-spacing="1">capture · synchronize · inspect · understand</text>
<text x="0" y="293" fill="#a6adc8" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">Android / C17 + Notcurses / Local Web / SQLite</text>
</g>
<g transform="translate(890 72)">
<rect width="470" height="336" rx="22" fill="#11111b" stroke="#45475a" stroke-width="2"/>
<path d="M0 58H470" stroke="#45475a" stroke-width="2"/>
<circle cx="28" cy="29" r="6" fill="#f38ba8"/>
<circle cx="50" cy="29" r="6" fill="#f9e2af"/>
<circle cx="72" cy="29" r="6" fill="#a6e3a1"/>
<text x="96" y="36" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">facts.timeline</text>
<path d="M34 222H436" stroke="#313244" stroke-width="2"/>
<path d="M34 118H436M34 170H436M34 274H436" stroke="#313244" stroke-width="1" stroke-dasharray="5 8"/>
<path d="M34 221H78L94 219L111 145L128 263L147 212H188L207 194L226 205L245 114L264 248L286 218H327L345 176L364 203L384 202L402 155L420 221H436" fill="none" stroke="#b4befe" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)"/>
<circle cx="111" cy="145" r="6" fill="#f38ba8"/>
<circle cx="245" cy="114" r="6" fill="#f38ba8"/>
<circle cx="402" cy="155" r="6" fill="#a6e3a1"/>
<rect x="34" y="292" width="112" height="24" rx="12" fill="#313244"/>
<circle cx="50" cy="304" r="5" fill="#a6e3a1"/>
<text x="63" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">local</text>
<rect x="158" y="292" width="136" height="24" rx="12" fill="#313244"/>
<circle cx="174" cy="304" r="5" fill="#89b4fa"/>
<text x="187" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">versioned</text>
<rect x="306" y="292" width="130" height="24" rx="12" fill="#313244"/>
<circle cx="322" cy="304" r="5" fill="#b4befe"/>
<text x="335" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">factual</text>
</g>
<rect x="1" y="1" width="1438" height="478" rx="27" fill="none" stroke="#313244" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -10,6 +10,16 @@ the wired recovery path. The separately configured private Drive transport
remains available to its legacy mirror/fallback workflow. No transport copies
SQLite.
Automatic Bluetooth restart recovery preserves the immutable generation
identity across conversation-run changes and recognizes the exact original-run
ACK by immutable generation ID and manifest digest before full SQLite
validation. An unacknowledged branch is excluded
from the bounded admission count only when a different sibling has a durable,
ledger-backed consumed acknowledgement; the superseded bytes and row remain
retained. The desktop service installer retires the exact obsolete private
ExecStart override that omitted required runtime paths and installs both
`trainlog-syncd` and the BlueZ RFCOMM agent.
Android synchronization paths share the same concurrency-guarded generation
coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background
@ -55,6 +65,12 @@ occurrences: opening an existing stable entry renders its prefilled form,
keeps the independent target intact on save, and leaves sibling occurrences
unchanged on save or cancellation. A set-based target-only occurrence can add
or change equipment without fabricating a performed set.
Session completion now also turns a completed target-only continuous timeline
into its measured performed duration when no supplemental field is required.
Program targets remain independent from facts, so a valid execution may contain
fewer or more sets and different repetitions or loads. A strictly session-bound
private recovery build can supply a past factual end without retiming exercise
markers or immutable heart-rate samples.
The full-generation path is operational on the paired daily installation.
Bluetooth Classic is the primary local byte transport, MTP remains the wired
@ -65,8 +81,8 @@ is idempotent and no transport carries SQLite. The Bluetooth transport does not
yet ingest heart-rate sensor data.
The public Android release chain advances from v0.1.1 versionCode 2 through
v0.1.2 versionCode 3 and v0.1.3 versionCode 4 to v0.1.4 versionCode 5 under
certificate SHA-256
v0.1.2 versionCode 3, v0.1.3 versionCode 4, v0.1.4 versionCode 5 and v0.1.5
versionCode 6 to v0.1.6 versionCode 7 under certificate SHA-256
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
Higher-numbered private validation builds used a separate Android Debug
certificate and are not members of the public update-signature chain.
@ -81,7 +97,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-22**.
Snapshot date: **2026-09-25**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
@ -112,11 +128,11 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
| Desktop SQLite | schema 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 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.4` stable, synchronized across Android and desktop; latest stable release: `v0.1.4` |
| Trainlog product version | `0.1.6` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
@ -134,7 +150,7 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sleep Diary V1 | `SLEEP_DIARY_V1=PASS`: timestamped causal capture, Android/Web editing, full-generation synchronization, exact 18:00-to-18:00 agenda projection, medication snapshots, and localized FR/EN vector PDF export |
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
@ -147,7 +163,24 @@ parallel implementation of its rules.
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_CARDIO_V1_NEXT_CONTRACT`: heart-rate sensor ingestion starts in 0.1.5 and remains distinct from the frozen Bluetooth synchronization transport |
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
| Recovered session HR tail | `TRAINLOG_HR_TRUNCATE_AFTER_SESSION_END_V1=PASS`: delayed Android finalization truncates BPM/RR strictly after the inclusive factual end, and the separate `trainlog-heart-rate-corrections` V1 companion reuses causal state without widening frozen causal-deletions V1. The paired-device recovery for `se_fa845379-794d-4bb6-8cd3-48187ebbd134` retained 3,429 BPM/RR facts through 11:16; two successive full MTP exchanges received consumed ACKs, and replay of the original 4,919-sample Heart Rate V1 artifact did not resurrect the tail. |
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. Réveil now creates a structured 30-minute `long_awake` window, repeated taps extend it, and Levé clamps it. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference |
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
| Current operational cursor | Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization recovery and factual session-finalization cycle; subsequent stabilization opens as 0.1.7 after the immutable release tag. |
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
@ -160,6 +193,34 @@ parallel implementation of its rules.
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
presentation-only and 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
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.
The detail shares one normalized time projection with declared sleep and
awakening intervals, exact Couché/Réveil/Levé markers, medication markers, and
full-hour ticks between exact visible bounds. Medication presentation retains
unit dose and quantity as separate facts. A missing declared sleep interval
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
data. Agenda, selected-night graph, and ranged PDF consume the same projection of
sleep intervals, every structured long-awake interval, point events, medication
intakes, and bounds. The agenda renders an estimated interval with distinct
hatching, never substitutes it for a factual interval, and retains the exact
shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by
every row, with each hour label centered inside the cell it names. Displayed
events use one factual night offset even when revision metadata is UTC, and
French agenda headings use `DD/MM/YYYY`. Displayed sleep duration is the sum of factual
sleep intervals or, in their absence, the displayed estimate minus overlapping
structured awakenings. The overlays remain available without an HR capture and
do not fabricate BPM.
Language is device-local presentation state. Android keeps it in dedicated
SharedPreferences; desktop keeps it in the XDG configuration file
`trainlog/presentation.conf`. A successful selection updates the UI
@ -286,6 +347,53 @@ the result. The Web aggregate now exposes the distinct factual snapshot through
## Local Web
The 0.1.6 refinement adds a persistent French/English preference and a dedicated
Settings route without moving the global synchronization action. Dashboard
activity is grouped by calendar month/ISO week, its progression tile is now a
global factual synthesis, and Analyse progresses from primary BODY ZONE to
exercise to individual detail. Exercise work time is derived exclusively from
`session_exercise_timeline` start/end facts; missing timelines remain unknown.
Measurements first expose unit-safe relative change cards before real-unit
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
The Dashboard layout keeps a 12-column canonical grid but permits every card
to reach full width and several useful heights; per-card minima, overlap
validation, exact save/cancel/reset semantics, and keyboard resizing remain
authoritative. Settings and synchronization form the rightmost Header action
cluster. Program progress uses the shared success color while retaining its
factual completed/total text. Responsive charts preserve their viewBox aspect
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
partial measured coverage instead of deriving time from sets or repetitions.
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
positions and every event, interval and medication marker use the same
coordinates: 18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00
at 100%. Ordinary desktop widths show the 13 two-hour labels, wide layouts show
all hours, and narrow layouts retain the five six-hour anchors without changing
the underlying geometry.
The Equipment route projects supplied and custom definitions with exercise,
history, preparation and Program reference counts. Its guarded merge accepts a
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
transaction, preserves occurrence/MAX facts indirectly through occurrence
identity, and writes the existing causal deletion boundary. An immutable
execution-draft JSON reference rejects the operation rather than being edited.
Browser mutation identities use the shared UUIDv4 helper backed by
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
so `http://trainlog.perf` remains supported.
An `execution_drafts` row marked active but containing no `exercises` or
legacy `entries` is an orphaned projection, not a resumable workout. Web may
delete that exact row through the existing revision-guarded causal transaction;
the durable tombstone prevents synchronization replay from resurrecting it.
An active draft with any occurrence remains protected from Web deletion.
Generation admission archives only rows backed by an exact correlated consumed
ACK and retains the newest two acknowledged lineage members. Legacy terminal
Android rows that predate the durable ACK ledger are neither fabricated nor
deleted, but no longer consume the active admission window. Captured,
published, waiting-ACK, and current acknowledged rows continue to count until
safe archival, so capacity recovery never prunes unacknowledged evidence.
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and
security invariants are canonical. The CLI/local HTTP infrastructure and
@ -405,10 +513,10 @@ The full Android lint comparison against an isolated clean `main` worktree is
unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy.
The stable Android 0.1.4 source uses `versionName=0.1.4` and public
versionCode 5. Private validation builds may still supply only a strictly
increasing `versionCode` as a build-time override; that does not create a
public release or change schemas and protocols.
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
Private validation builds may still supply only a strictly increasing
`versionCode` as a build-time override; that does not create a public release
or change schemas and protocols.
The private Program execution rollout installed desktop schema v27 and the
signed nondebuggable Android versionCode 17/schema v25 update without

View file

@ -1,5 +1,125 @@
# Desktop database
## Causal heart-rate tail correction (schema v36, no migration)
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
`sync_causal_operations` and `sync_causal_state` tables for the additive local
`heart_rate_tail` state. The frozen `trainlog-causal-deletions` V1 artifact
continues to emit and accept exactly its original seven target kinds. A heart-
rate correction's composite target contains the stable capture identity and an
inclusive offset-aware cutoff. Applying it deletes only BPM samples strictly
after the cutoff; declared RR foreign keys cascade those exact children, and
the capture end becomes the cutoff in the same transaction. The predecessor
revision hashes the original ordered raw capture, including RR values, so a
correction cannot be silently applied to a different measurement history.
The immutable Heart Rate V1 generation remains audit evidence. Import applies
known causal cutoffs before stable-identity comparison or insertion, which
makes old-generation replay idempotent without rewriting its manifest or ACK.
No schema or frozen `TRAINLOG_FORMAT_V1` change is involved.
## Schema v36: append-only Sleep revision payloads
Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for
Sleep diary revisions, their events and medication intakes, and medication
catalog revisions. A stable revision identity therefore cannot be detached
from its canonical parent or payload even through a direct SQLite write.
Normal correction remains append-only: it inserts a fresh UUIDv4 revision and
advances the entry or medication current pointer transactionally. The migration
is additive and does not rewrite any existing Sleep identity, payload, causal
edge, generation membership, publication state, or acknowledgement.
Importers additionally compare the immutable payload of every already-known
Sleep revision before treating an incoming ancestor as stale/idempotent. Thus
an exact historical replay remains accepted, while reuse of the same identity
with a different parent or content is an explicit collision.
## Schema v35: Sleep Diary quantity V2
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required
integer in `1..99` with default `1`. It is the count of the separately stored
per-unit dose, not a free-text convention and not a total-dose replacement.
Thus `75 mg` with quantity `2` retains `dose_value=75`, `dose_unit=mg`, and
`quantity=2`. Existing immutable intake rows read as one unit; the migration
does not alter their revision identity, timestamps, causal ancestry, events,
or medication snapshot.
Sleep Diary V1 remains a frozen historical input. Quantity is carried by the
separate V2 companion; desktop v35 validates the V2 bounded value while still
hydrating historical V1 input as quantity one.
## Schema v34: synchronized Cardio guidance V1
Schema v34 additively stores completed Android-owned guided-cardio runs. `cardio_guidance_runs` keeps the stable run/session identity and exact run bounds. `cardio_guidance_phases` stores the ordered `entry_id`-owned phase, its immutable resolved BPM target, optional exact calibration provenance, exit condition, phase bounds and final instruction. `cardio_guidance_events` stores only instruction changes, not another copy of the raw heart-rate curve.
The importer requires the referenced canonical session to be `cardio`, every phase `entry_id` to exist in that session, and every phase interval to remain inside the synchronized exercise timeline. Calibration-derived targets must reference an imported calibration with the same observed peak. Any guidance event carrying BPM must match an exact synchronized raw heart-rate sample at the same timestamp; suspended events intentionally carry no BPM.
No migration invents phases, instructions, BPM or calibration references.
## Schema v33: synchronized Cardio calibration V1
Schema v33 additively stores completed Android-owned Cardio calibration profiles. `cardio_calibrations` is keyed by stable `calibration_id` and retains protocol version, canonical cardio `session_id`, stable occurrence `entry_id`, exact calibration/effort/end timestamps, the synchronized heart-rate `capture_id`, observed peak BPM and import time.
`cardio_calibration_recovery` stores only factual recovery samples for the defined +60, +120 and +180 second references. The source curve remains Heart Rate V1; the calibration importer verifies that the observed peak and every recovery `(observed_at, bpm)` pair exist in that raw capture. It never derives, interpolates or rewrites HR samples.
Android owns the active calibration lifecycle. Desktop stores only completed profiles. A later calibration creates a new stable profile and never rewrites an older session or reference.
## Schema v32: canonical cardio session kind
Schema v32 adds the product-owned `sessions.session_kind` column with the
explicit domain `training | max_test | cardio`. The pre-existing
`sessions.session_type` column remains intentionally frozen to
`training | max_test` for compatibility with historical codecs and MAX
semantics. A cardio row therefore stores `session_type=training` only as a
legacy compatibility projection and `session_kind=cardio` as the canonical
product fact.
The v31→v32 migration introspects historical `sessions` layouts before
backfill. When the legacy `session_type` column exists it seeds
`session_kind` from it; very old historical fixtures that legitimately lack
that column keep the session kind produced by their earlier migration chain.
No existing session identity, occurrence, set, timeline, Program provenance or
heart-rate fact is rewritten.
Completed cardio sessions synchronize Android→desktop through the separate
optional `trainlog-cardio-sessions` V1 generation companion. The frozen
mobile/history V4 and execution-draft formats are not widened to a third
session type.
## Schema v31: synchronized session exercise timeline
Schema v31 additively owns Android-recorded execution timing for completed
session occurrences in `session_exercise_timeline`. Each row is keyed by the
stable `session_id + entry_id` pair and stores the matching catalogue
`exercise_id`, the real user-action `started_at` and `ended_at`, and import
time. The importer requires the existing canonical session and occurrence to
match; it never creates workout history or infers exercise timing from order,
sets, plans, or heart-rate samples.
The session bounds remain the existing canonical `sessions.started_at` and
`sessions.ended_at`. The separate `trainlog-session-timeline` V1 companion
must match those exact values before occurrence timing can be imported.
## Schema v30: synchronized Heart Rate V1
Schema v30 additively owns Android-recorded heart-rate captures after
full-generation synchronization. `heart_rate_captures` stores one immutable
stopped capture with its stable `hrc_` identity, owner kind (`session` or
`sleep`), stable owner identity, exact start/end timestamps, optional sensor
name, and import time.
`heart_rate_samples` stores the received timestamp, measured BPM, optional
exercise `entry_id` snapshot, optional sensor-contact state, and optional
cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw
Heart Rate Service RR values in their native 1/1024-second units. Desktop never
creates these measurements and never connects to the sensor.
The v29→v30 migration is additive and creates no cardio row. Heart-rate data
travels Android→desktop through the separate optional `trainlog-heart-rate`
V1 generation companion. Exact replay is idempotent; a stable capture/sample
identity with different measured content is rejected. Existing workout,
Sleep Diary, and frozen mobile formats are unchanged.
## Schema v29: Sleep Diary V1
Schema v29 additively owns `sleep_diary_entries`, immutable
@ -9,14 +129,14 @@ one night and its associated day. Point events have an offset-bearing
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells.
The same unreleased migration owns `sleep_medications`, immutable
The same stable v0.1.4 migration owns `sleep_medications`, immutable
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
Catalog defaults assist capture but do not own historical facts: every intake
stores the displayed medication name and effective optional positive dose/unit
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
past entry. Development databases that had already opened the earlier v29
draft are completed idempotently on open; `user_version` remains 29 and no
existing row is rewritten.
draft are completed idempotently on open before the additive v30 migration;
no existing sleep row is rewritten.
Mutations advance `current_revision_id` only when the caller supplies the
current parent. Logical deletion advances the revision and retains the

413
docs/design/cardio_v1.md Normal file
View file

@ -0,0 +1,413 @@
# Cardio V1
Status: `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`
This document is the implementation contract for Trainlog 0.1.5 cardio,
training-timeline correlation, and Android one-tap sleep capture. It does not
declare the implementation complete.
## Ownership and hard boundaries
Android is the only heart-rate sensor client. It owns BLE discovery,
connection, reconnection, Heart Rate Measurement parsing, local persistence,
capture lifecycle, and the live BPM presentation.
Desktop, TUI and Web never scan, pair with, or connect directly to a
heart-rate sensor. They consume only measurements already recorded by Android
and synchronized through Trainlog.
Heart-rate BLE/GATT and Trainlog Bluetooth synchronization are independent:
```text
heart-rate sensor -- BLE/GATT --> Android
Android <-------- Trainlog full-generation --------> desktop
over
Bluetooth Classic RFCOMM or MTP recovery
```
The cardio implementation must not reuse the frozen Bluetooth Classic service
UUID, framing, peer identity, connection state, or transport protocol.
## Standard sensor target
Cardio V1 targets the Bluetooth LE Heart Rate Service:
- Heart Rate Service: `0x180D`;
- Heart Rate Measurement: `0x2A37`.
The parser must support the standard 8-bit and 16-bit BPM forms, optional
sensor-contact state, optional cumulative energy expended, and zero or more RR
intervals. RR intervals are preserved as raw integer units of 1/1024 second.
Battery Service may be observed as optional device metadata but is not required
to retain a heart-rate sample.
Malformed notifications are rejected. Trainlog never manufactures missing BPM
or RR values.
## Global Android heart-rate indicator
The Android application shell exposes one compact indicator at the top right:
```text
♥ 82 BPM
```
Only the heart and BPM are shown. Sensor name, address, and technical state are
not shown in the global header.
Heart colour describes connection freshness only:
- grey: known sensor unavailable/disconnected;
- orange: connecting, reconnecting, or the last measurement is stale;
- green: connected and receiving fresh valid measurements.
Colour never indicates a medical zone or whether the BPM is high or low.
When no fresh measurement exists, the value is `-- BPM`. A stale previous
number must never remain visible as if it were current.
With a fresh BPM the heart uses a restrained pulse animation derived from
`60 / bpm`. This is only a visual representation of the current measured
rate; it does not claim knowledge of the exact instant of each contraction.
## Capture identities and contexts
One capture has a stable `hrc_<uuid-v4>` identity and exactly one semantic
context:
- `session`: a normal training or MAX session identified by stable
`se_<uuid-v4>`;
- `cardio`: a dedicated cardio session identified by stable
`se_<uuid-v4>`;
- `sleep`: one Sleep Diary entry identified by stable `sl_<uuid-v4>`.
Context is never inferred from a title, clock date, exercise name, or display
position.
A workout/cardio sample may additionally snapshot the stable active occurrence
`sxe_<uuid-v4>` at the instant the sample is received.
## Raw measurement contract
Each received measurement stores at minimum:
- `capture_id`;
- monotonically increasing `sequence` within that capture;
- offset-bearing `observed_at` reception timestamp;
- integer `bpm`.
When actually supplied by the sensor it may also store:
- sensor-contact state;
- cumulative energy-expended value;
- zero or more RR intervals in 1/1024-second units.
For workout/cardio captures it may store the active `entry_id` snapshot.
Raw storage preserves signal gaps. It does not interpolate, smooth, estimate,
diagnose, infer sleep stages, infer a return to sleep, or synthesize missing
measurements.
## Android durability
Android SQLite owns authoritative field capture. An active capture remains
durable across application navigation and ordinary process/lifecycle
transitions supported by Android. Long-running acquisition uses a dedicated
foreground service distinct from synchronization.
Cardio V1 permits one active heart-rate capture at a time.
Sensor disconnect does not delete or close a capture. Reconnection resumes
future measurements and leaves the missing interval as a real gap.
Explicit stop closes the capture with its actual `ended_at`.
## Synchronization contract
Recorded cardio is synchronized Android -> desktop as the separate optional
full-generation companion:
```text
logical_name = heart-rate
format = trainlog-heart-rate
version = 1
filename = heart-rate-v1.json
```
Desktop persists and presents these measurements but is not a sensor producer.
Normal publication contains completed/stopped, not-yet-acknowledged captures.
An active capture is retained locally and is not emitted as a mutable
full-generation snapshot.
Identity is idempotent:
- capture key: `capture_id`;
- sample key: `capture_id + sequence`;
- RR key: `capture_id + sequence + rr_index`.
Exact replay is a no-op. Reusing an identity with different measured content
is rejected.
The exact captured `capture_id -> generation_id` relation is durable. Only a
correlated consumed ACK marks those captures acknowledged. A rejected
generation does not acknowledge them. Acknowledged captures remain in Android
history but are excluded from ordinary future publication.
The companion does not alter `TRAINLOG_FORMAT_V1`, mobile-export V3/V4,
Sleep Diary V1, generation/ACK framing, or Bluetooth Classic sync V1.
## Training timeline correlation
A training session must expose real time boundaries, never reconstructed
timings.
Session start/end use the existing stable session identity and canonical
`started_at` / `ended_at` lifecycle when those values are already the
authoritative timestamps.
Each session occurrence has an explicit execution lifecycle:
```text
not_started -> in_progress -> completed
```
Starting an exercise records its actual start timestamp against
`session_id + entry_id`. Ending it records its actual end timestamp.
Only one occurrence may be active at a time. Starting another occurrence while
one remains active requires an explicit resolution rather than silently
inventing an end.
Samples received while an occurrence is active snapshot that `entry_id`.
Samples between occurrences remain valid session samples with no fabricated
exercise ownership and represent rest/transition.
Ending a session while an occurrence is active requires an explicit UI action
to close the occurrence at the validated finalization instant.
The synchronized history must permit factual curve markers for:
- session start;
- exercise start;
- exercise end;
- session end.
Future set-level `set_start` / `set_end` events may extend this model, but
Cardio V1 must not infer them from reps, duration targets, or form edits.
## Dedicated cardio session type
Trainlog 0.1.5 adds a real third session type:
```text
training
max_test
cardio
```
Every existing boundary that assumes only `training|max_test` must be audited.
A frozen artifact may not be silently widened. If an existing wire codec
cannot safely express `cardio`, add a separately versioned compatible
extension while retaining legacy readers.
A cardio session consists of cardio exercises and optional phases. Initial
exercise families include treadmill, bicycle, elliptical, rower, stepper,
walking, running, and custom cardio.
A phase may express a type, target, duration, and exit condition. Required
phase concepts include warm-up, work, recovery, and cool-down.
The motor must be able to represent fixed duration, reaching a BPM zone,
recovering below a threshold, and bounded combinations of time and
heart-rate conditions.
Machine speed, resistance, incline, and distance may be recorded, but BPM is
the primary guidance signal.
## BPM-guidance motor
Cardio V1 guidance outputs are:
```text
ACCÉLÈRE
MAINTIENS
RALENTIS
```
Guidance is based only on fresh measurements and the phase target.
Hysteresis and a minimum persistence interval prevent oscillation around a
boundary. One isolated sample must not cause rapid instruction changes.
When the signal becomes stale, guidance is suspended. The global heart becomes
orange and BPM becomes unavailable rather than continuing from an old value.
## Calibration cardio exercise
The first dedicated cardio exercise is `Calibration cardio`.
Its purpose is to establish a personal measured reference for later guided
exercise. Trainlog records an **observed peak BPM**, not an automatically
asserted physiological maximum.
The protocol is progressive:
```text
warm-up -> easy stage -> progressive stages -> user-chosen highest tolerated
effort for that day -> stop effort -> recovery observation
```
A manual stop is always immediately available. The motor never attempts to
beat the previous observed peak automatically.
Each calibration is immutable/versioned and retains at minimum:
- stable `calibration_id`;
- protocol version;
- performance timestamp;
- associated heart-rate capture;
- observed peak BPM;
- effort-end timestamp;
- recovery timeline.
Recovery projections may expose +1, +2 and +3 minute facts while the complete
raw curve remains authoritative.
A later calibration creates a new reference. It never rewrites an older
calibration or a prior guided session.
Every guided cardio session snapshots the exact `calibration_id` and target
thresholds used at that time.
Measured, user-configured, and calculated/estimated values remain explicitly
distinguishable.
## Android Sleep entry point
Android adds a first-class `Sommeil` menu entry backed by the existing Sleep
Diary domain. It must not create a parallel sleep database or incompatible
sleep truth.
Night-time interaction is deliberately minimal. The primary screen exposes:
```text
[ COUCHÉ ]
[ médicament 1 ] [ médicament 2 ]
[ médicament 3 ] [ + ]
[ RÉVEIL ]
[ LEVÉ ]
```
Buttons are large, visually consistent with Trainlog, and usable while only
partly awake.
### Couché
One tap records the actual `bed_time` timestamp in the correct stable Sleep
Diary entry. If heart-rate acquisition is available, the nocturnal capture is
linked directly to that `sleep entry_id`.
### Médicaments
Active configured medications are one-tap actions. A tap stores the actual
intake timestamp plus stable medication identity and the effective
name/dose/unit snapshot. Full editing remains available later.
### Réveil
There is no `Rendormi` button. One tap records a structured `long_awake`
interval beginning at the factual tap time and ending 30 minutes later under
the explicit capture policy. Nothing else is required before the user tries to
return to sleep. A second tap within that window resets its end to 30 minutes
after the latest tap. Levé truncates an overlapping interval to the factual
final-get-up timestamp.
The 30-minute end is user-approved product policy, not an inference from BPM or
RR data. Later correction remains available when the actual duration is known.
### Levé
One tap records the actual `final_get_up` timestamp, closes the night, and
stops the linked nocturnal heart-rate capture when one is active.
### Immediate undo and later correction
After Couché, medication, Réveil, or Levé, Android shows a short-lived action
such as:
```text
Réveil enregistré à 02:17 [ ANNULER ]
```
Undo is scoped to that exact action and must not roll back unrelated newer
state.
`Corriger la nuit` provides later editing of timestamps, medication
intakes, awakening intervals, forgotten events, and existing subjective
Sleep Diary fields.
The night-time rule is: one tap, one 30-minute awakening window, then no further
required interaction.
## Sleep + cardio factual timeline
A nocturnal capture is owned by stable `sleep entry_id`, not only a date.
After synchronization, Analyse may superpose factual events and measurements:
- Couché;
- medication intakes;
- structured 30-minute awakening windows;
- Levé;
- BPM;
- RR when supplied;
- real sensor disconnect/reconnect gaps.
Trainlog does not diagnose sleep, invent sleep stages, or claim a certain
return-to-sleep instant from heart rate alone.
## Web/Analyse projection
Web and desktop consume synchronized facts only.
Training views may show the BPM curve, session bounds, exercise intervals,
rest/transition gaps, and factual min/max/mean projections.
Cardio views may additionally show phase targets, chronological guidance,
calibration identity, and measured response versus target.
Sleep views may overlay Sleep Diary events with BPM/RR measurements.
Graphs are projections. Persistent measurements, identities, and timestamps
remain the source of truth.
## Performance bounds
One night can contain tens of thousands of samples. Storage and exchange must
use bounded artifacts, indexed temporal queries, transactional import, and
avoid per-sample N+1 behavior on analysis paths.
Trainlog does not arbitrarily downsample the authoritative raw capture merely
for transport convenience. Presentation may derive bounded projections while
retaining the recorded source data.
## Implementation order
1. audit/freeze this contract;
2. Android + desktop persistence and strict heart-rate companion exchange;
3. standard Heart Rate Measurement parser and real-sensor diagnostics;
4. Android foreground acquisition service and global ♥ BPM indicator;
5. training start/end and exercise start/end timeline;
6. Android one-tap Sleep UI and sleep-linked capture;
7. true `cardio` session type;
8. versioned Calibration cardio exercise;
9. BPM-guided phase motor;
10. Web factual visualization;
11. complete regression, migration, hardware, replay, and restart validation.
No stage permits Web/TUI sensor connection.

View file

@ -691,6 +691,30 @@ enumerate at most 4096 operations plus one sentinel row, and a local deletion
is refused before its effect if the complete protection artifact would exceed
either bound.
The V1 `target_kind` set remains the frozen seven-kind set: `session`,
`execution_draft`, `exercise`, `body_observation`, `custom_equipment`,
`feedback`, and `body_zone_relation`. Heart-rate correction does not widen this
published contract.
## Heart-rate corrections V1
`trainlog-heart-rate-corrections` V1 is a separate optional Android→desktop
full-generation companion named `heart-rate-corrections`, stored as
`heart-rate-corrections-v1.json`. Its root contains `format`, integer `version`,
`generated_at`, and at most 256 `corrections`; the complete UTF-8 artifact is
limited to 4 MiB. Each correction contains a stable UUIDv4-based `operation_id`,
stable `capture_id`, inclusive offset-aware `cutoff`, `creator_id`, exact
`predecessor_revision_id`, `created_at`, and canonical `payload_sha256`.
The predecessor hashes the complete original capture metadata, ordered raw BPM
samples, and ordered RR children. The consumer requires the cutoff to equal the
canonical session end and not precede the session start or latest exercise end.
It deletes only samples strictly after the cutoff in one transaction, relies on
the declared RR cascade, records durable causal state, and applies that state
before any Heart Rate V1 snapshot in the same generation. Unknown fields,
duplicate keys/identities, digest mismatch, ancestry mismatch, and incompatible
capture/session ownership reject the containing generation atomically.
## Staged generation manifest and acknowledgement V1
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains

View file

@ -6,6 +6,8 @@ than later development. GitHub and Forgejo publication must preserve the
existing release, tag, status, and assets while keeping their note bodies equal
to the corresponding source file.
- [Trainlog v0.1.6](v0.1.6.md)
- [Trainlog v0.1.5](v0.1.5.md)
- [Trainlog v0.1.4](v0.1.4.md)
- [Trainlog v0.1.3](v0.1.3.md)
- [Trainlog v0.1.2](v0.1.2.md)

59
docs/releases/v0.1.5.md Normal file
View file

@ -0,0 +1,59 @@
Trainlog v0.1.5 completes the factual cardio capture cycle and makes the
Android-to-Web Sleep workflow ready for real nightly use without adding
medical, sleep-stage, or causal inference.
## Heart-rate capture and cardio sessions
- Android persistently acquires real CYCPLUS Heart Rate Service measurements,
retains factual observation timestamps, BPM and raw RR values, and exposes a
global fresh/stale/disconnected indicator.
- Training occurrences have explicit start/end timelines so synchronized
heart-rate samples retain their stable exercise ownership instead of
inferring it later from time overlap.
- Dedicated `cardio` sessions, measured calibration profiles and fresh-signal
BPM-guided phases use separate versioned companions and preserve the frozen
historical session formats.
- Guidance suspends on stale signal and records measured facts only; it does
not estimate fitness, recovery, readiness or medical state.
## Factual Web timelines
- History and Sleep display synchronized heart-rate samples through one
bounded native read model and responsive Web timeline.
- Full-source min/mean/max and RR counts remain exact while large visual
projections are bounded.
- Cardio timelines show factual occurrence bounds, resolved targets,
instruction changes and measured calibration points.
- Sleep highlights notable relative rises and falls with a deterministic local
median rule, explicitly without assigning a sleep stage or cause.
## Sleep capture and medication quantity
- Medication can be recorded before Couché. The first intake creates one
durable pending 18:00-to-18:00 night; Couché advances the same stable entry,
Réveil leaves heart-rate capture running, and Levé closes it at the factual
final-get-up timestamp.
- A missing sensor fabricates neither a capture nor a sample.
- Sleep Diary V2 carries structured per-intake quantity while published V1
remains strict and readable; historical intakes default to quantity one.
- The Web agenda renders every night in the selected period on one aligned
18:00-to-18:00 axis, and the selected night owns the full-width heart-rate
detail. PDF reports retain their multi-night behavior and display quantity
without replacing per-unit dose.
## Compatibility and validation
- Desktop schema v35 and Android schema v32 are additive migrations.
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, causal revision
lineage, generation/ACK semantics and Bluetooth/MTP transport ownership are
unchanged.
- Native tests, Android unit tests and APK assembly, Web tests/typecheck/build,
JSON/import validation, changed-C formatting and exact Sleep V2 replay all
pass at the release tag.
## Assets
- `trainlog-android-v0.1.5.apk`: signed public Android APK.
- `trainlog-tui-linux-x86_64-v0.1.5`: Linux x86-64 runtime bundle containing
the TUI, local Web adapter, synchronization helpers and required catalogs.
- `SHA256SUMS`: SHA-256 checksums for both artifacts.

64
docs/releases/v0.1.6.md Normal file
View file

@ -0,0 +1,64 @@
Trainlog v0.1.6 closes the Web refinement and synchronization-recovery cycle.
It improves factual Sleep and training presentation, preserves immutable
history through difficult replay and recovery cases, and gives the repository
a durable Catppuccin-based project entry point without widening frozen product
contracts.
## Local Web refinement
- Dashboard activity uses calendar months and ISO weeks, progression is a
factual global synthesis, and Analyse drills from BODY ZONES to exercises and
measured detail without inventing missing exercise time.
- A persistent French/English preference, Settings route, denser shared
surfaces, responsive charts, and a shared BODY ZONES figure refine the local
interface while presentation state remains outside business SQLite.
- Equipment administration can transactionally merge a custom duplicate into
a canonical identity while preserving history, MAX context, preparations,
Programs, and causal deletion evidence.
## Sleep facts and heart-rate timelines
- The multi-night agenda, selected-night heart-rate detail, and vector PDF use
one temporal projection of factual sleep intervals, awakenings, point events,
medication occurrences, and exact bounds.
- The shared 18:00-to-18:00 agenda keeps exact hourly geometry at desktop and
narrow widths. A clearly labelled fallback estimate is presentation-only,
non-medical, and never written to Sleep data.
- Android wake capture records revisioned long-awake intervals, medication
quantity survives persistence and synchronization, and Sleep revision
payloads are append-only on both platforms.
## Synchronization and recovery
- Bluetooth restart recovery retains immutable generation identity and accepts
the exact original generation/digest acknowledgement across conversation
runs; direct MTP remains the wired recovery transport.
- Android foreground and automatic synchronization share one ownership arbiter,
and direct-MTP publication waits for MediaProvider visibility before exposing
a mutable generation reference.
- Divergent Sleep revision identities can be recovered without choosing an
arbitrary winner. Strict payload checks reject reuse of a historical revision
identity with different parentage or content.
- A separate heart-rate correction companion removes samples strictly after a
factual session end and prevents an older immutable generation from
resurrecting the removed tail.
## Compatibility and validation
- Desktop schema v36 and Android schema v33 are additive migrations.
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, generation/ACK
semantics, and Bluetooth/MTP transport ownership remain unchanged.
- The default automatic exchange remains V3; full-generation synchronization
remains behind its trusted local opt-in.
- Native Meson and Clang ASan/UBSan suites each pass 114/114. Android passes
317 tests (312 passed and five documented optional skips), debug assembly,
signed release assembly, and signature verification. Web passes 32 files / 209
tests plus typecheck/build; JSON/import validation, changed-C formatting, and
diff checks also pass at the release tag.
## Assets
- `trainlog-android-v0.1.6.apk`: signed public Android APK.
- `trainlog-tui-linux-x86_64-v0.1.6`: Linux x86-64 runtime bundle containing
the TUI, local Web adapter, synchronization helpers, and required catalogs.
- `SHA256SUMS`: SHA-256 checksums for both artifacts.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -13,7 +13,7 @@ endobj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
endobj
5 0 obj
<< /Length 17659 >>
<< /Length 17665 >>
stream
0 G 0 g
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
@ -53,8 +53,8 @@ stream
0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET
0.7 G 30 498 782 40 re S
0 g BT /F1 5 Tf 33.0 525.0 Td (20/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 21/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 525.0 Td (23/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 24/09/2026) Tj ET
0.9 G 112 498 m 112 538 l S
0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S
0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S
@ -133,8 +133,8 @@ stream
0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 482.0 Td (TB) Tj ET
0 g BT /F1 7 Tf 696.0 482.0 Td (Moy) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg | Nuit de co) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td (ntr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg \3272 | Nuit de) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td ( contr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET
0.7 G 30 418 782 40 re S
@ -383,5 +383,5 @@ xref
trailer
<< /Size 6 /Root 1 0 R >>
startxref
18059
18065
%%EOF

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View file

@ -0,0 +1,103 @@
# Sleep awake selection and PDF V1 evidence
`TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS` on 2026-09-24.
The Web now derives the agenda, selected-night heart-rate overlay, and vector
PDF rows from one pure temporal projection. It retains every structured
`long_awake` interval separately from point-in-time `night_get_up` events,
medication intakes, and sleep ranges. Factual `sleep` intervals determine sleep
duration when present. Otherwise the displayed Couché +45 minutes to Levé -10
minutes range supplies a presentation-only duration after subtracting the union
of overlapping structured awakenings; no estimate is persisted.
One agenda row is the effective selection. A click updates the detail dates,
editor snapshot, and HR context in memory without issuing a write. PDF preview
and download receive the same explicit inclusive range over each entry's
`night_start_date`, and all observations are recomputed from that subset. The
range initially spans the loaded agenda period; later detail/HR selection does
not alter it. Each of the 24 hour labels is centered in the equal visual cell
it names, using the same grid beneath every 18:00-to-18:00 agenda row; there is
no ambiguous duplicate `18` at the right edge.
A real-data follow-up found that using UTC revision `created_at` as the visual
offset source moved `+02:00` events two hours left. The agenda now derives one
row-wide offset from factual bedtime/event data. Regression coverage mixes UTC
metadata with `+02:00` events and keeps the 04:45 final get-up at its local
04:45 position. French night headings render `DD/MM/YYYY`.
## Automated validation
- Web targeted suites: 34/34 passed.
- Complete Web suite after the range/axis follow-up: 209/209 passed.
- `npm run typecheck`: passed.
- `npm run build`: passed.
- Embedded production bundle `meson compile -C build`: passed.
- The tests cover multiple awakening intervals in agenda/HR projection, exact
factual sleep duration, estimated duration with awakening subtraction,
selection dates/HR identity, no write on selection, identical preview/export
subsets, PDF interval geometry, medications, point markers, and no-HR
fallback without an invented BPM curve.
## Read-only real-data smoke
The rebuilt user Web service was exercised through real headless Firefox at
`http://trainlog.perf/analyse?section=sleep` without a Sleep mutation.
- The real 2026-09-23 → 2026-09-24 row contains no factual `sleep` interval;
its displayed estimated band now reports `5 h 14 min 16 s`, not `—`.
- The real 2026-09-22 → 2026-09-23 row renders its structured awakening in
both the agenda and the no-invented-HR detail overlay.
- Selecting that row updates the detail inputs to exactly 2026-09-22 and
2026-09-23.
- Firefox export produced one PDF page whose extracted text contains only
`22/09/2026`, `au 23/09/2026`, and `1 nuit`; it contains no 24/09 row.
- The selected-subset observation is `Sommeil déclaré : 6 h`,
`Longs réveils : 1`, `Siestes : 1`, and `Prises de médicaments : 7`.
- Raster inspection confirms the long-awake band remains visibly distinct
inside the sleep timeline and point/medication markers retain their positions.
The original Web change affected no database schema, synchronization artifact,
frozen format, or Android behavior; the follow-up below deliberately corrects
the Android capture semantics without changing those compatibility boundaries.
## Android capture correction and real-night repair
Follow-up visual review established that Android one-tap Réveil had produced
three punctual `night_get_up` facts for 2026-09-23 → 2026-09-24, so there was
no structured interval for the shared projection to color. The user selected
an explicit 30-minute no-further-action policy. Future taps now create a
`long_awake` interval from the factual tap through +30 minutes; another tap
inside the window extends that same interval, while Levé clamps it to the
factual final-get-up timestamp. This is product policy, not BPM/RR inference.
Before repair, separate desktop and Android backups were retained under
`~/.local/share/trainlog/backups/sleep-wake-window-20260924T0733/`:
- desktop SHA-256: `ac2246dc699a5e11e9d8b52c4cad0f096dfc08191f54b6a86c44ce72f623c39c`;
- Android SHA-256: `a1a9017875757f27264556ea62a9d27a98f2efad17ca0234fb10f4095170a98a`.
The real night was advanced causally, retaining its stable `entry_id`, to:
- `01:35:43.554104 → 02:05:43.554104`;
- `04:11:59.245980 → 04:14:28.358915` (bounded by Levé).
Android and desktop current revision is
`slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa`. Its repaired linear ancestry
retains desktop revision `slr_2bbf573d-4262-49b0-896e-1bcabf3b002a`, including
the existing `TB` sleep and wake qualities.
Android database round-trip hashes matched, integrity/foreign-key checks passed,
and the non-destructive app update preserved first-install identity while
advancing the private build to versionCode 49.
The first post-repair full-generation attempts correctly rejected an already
published Android archive whose embedded ancestry still named the pre-desktop
mobile parent. Both live databases are repaired and aligned, but that stale
transport archive remains rejected until the normal archive-capacity recovery
retires it; no successful synchronization receipt is claimed by this evidence.
Post-correction validation passed 312 Android JVM tests (five optional skips),
`assembleDebug`, 208 Web tests, typecheck, and build. Real Firefox then observed
two orange agenda bands, two orange HR bands, `Réveils 2 · 32 min 29 s`, and two
orange PDF intervals. The selected-only PDF reports `Sommeil déclaré : 4 h 44
min 16 s` after subtracting the repaired awakening intervals from the visual
estimate.

View file

@ -0,0 +1,73 @@
# Sleep revision collision recovery V1 evidence
Date: 2026-09-24
## Collision
```text
COLLIDING_REVISION_ID=slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa
ANDROID_PARENT=slr_e98ad36c-94e2-452c-8068-a7e3d2ea4dd4
DESKTOP_PARENT=slr_2bbf573d-4262-49b0-896e-1bcabf3b002a
ANDROID_PUBLISHED=YES
DESKTOP_PUBLISHED=NO
ANDROID_REFERENCED_BY_IMMUTABLE_GENERATION=YES
DESKTOP_REFERENCED_BY_IMMUTABLE_GENERATION=NO
```
The two records had the same entry identity, night envelope, creation time,
notes, day form, and four event identities (`IDENTICAL`). Their causal parents
were different (`PARENT_CONFLICT`). Desktop contained two non-null quality
values where Android retained nulls (`NON_CONFLICTING_ADDITION`). Desktop also
contained five distinct medication occurrences absent from the older Android
branch (`MISSING_ON_ANDROID` / `NON_CONFLICTING_ADDITION`), with a total
quantity of six. No same occurrence ID had divergent content. Medication names,
unit doses, and notes are deliberately omitted from this repository record.
The root cause was the manual real-night causal repair recorded in
`docs/reviews/sleep_awake_selection_pdf_v1_evidence.md`: two independently
derived tips were assigned the same preselected new revision ID. No production
UUID collision or repository clone function produced the reuse. The faulty
transition was therefore that operational repair, not an Android/Desktop save
function. The production boundaries hardened by this tranche are
`sleep_diary_exchange.apply()` and
`TrainlogRepository.applySleepDiaryV1Json()`; database schemas v36/v33 also
make revision payloads append-only.
## Backups and isolated simulation
Immediate pre-recovery evidence is outside Git at
`/home/fy59/Downloads/trainlog-sleep-collision-recovery-20260924T140000+0200/`.
The Android application archive SHA-256 is
`7d0d95c9401df4baa66503658b8e309e4f6cb7359e5404517460a97afbce668a`,
the extracted Android database SHA-256 is
`6fb99e87a24041202a867e8487dd95ef088f8e189ebb6d6e60956f999bc01ae0`,
and the Desktop database SHA-256 is
`4a44a29a68683ddfaea4c20c121cd6f32309660d6b4141ca716c3fb20d89b225`.
Both databases reported `integrity_check=ok` and an empty foreign-key check.
Before canonical mutation, isolated copies retained both branches, replayed
the immutable generation, produced `consumed` / `sqlite-commit-full`, and
replayed the exact ACK byte-identically. The imported fixture had one session
ending `2026-09-24T11:16:00+02:00`, 7 exercises, 15 sets, 5 feedbacks,
7 exercise intervals, 4,919 BPM samples, 4,919 RR intervals, and one Program
execution. Sleep retained four events, five medication occurrences, and total
quantity six; both copied databases remained integrity/FK clean.
## Canonical recovery and replay
The unpublished Desktop branch was retained exactly under
`slr_42250548-5237-4225-a12d-7e68a0b57076`. The immutable Android branch kept
the collision identity and its original parent. The reconciliation successor
`slr_670c7516-f39e-4196-a861-8f3b657dbee1` descends from that published branch
and contains the non-conflicting union. The target manifest remains SHA-256
`ea3e7ed5622a04f514db069db29cccff1b4702c5cc0e4e253a578ffe8806fc70`;
no generation artifact was rewritten.
Generation `gen_76222040-50d7-4f5d-bcfb-ad9c1e7786f8` was consumed through the
production consumer with `sqlite-commit-full`, and Android accepted its exact
consumed ACK. Two subsequent normal Android generations were also consumed and
acknowledged during the recovery validation. The canonical database and Web
API expose the single original session identity at 11:16, all 7 exercises,
15 sets, 5 feedbacks, 4,919 BPM/RR values, one Program execution, and the
reconciled Sleep tip with four events, five medication occurrences, and total
quantity six.

View file

@ -30,14 +30,14 @@ estimates.
## Current baseline
Stable v0.1.4 is the release baseline for subsequent development.
Desktop schema v29, Android schema v26, Notcurses, direct `Documents/Trainlog`
Stable v0.1.6 is the release baseline for the next stabilization cycle.
Desktop schema v36, 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
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
evidence, but its September 2026 observations must be revalidated against the
v0.1.4 baseline rather than treated as a current implementation plan.
v0.1.6 baseline rather than treated as a current implementation plan.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
contract, frozen interactive grid, versioned layout persistence and factual
@ -46,8 +46,10 @@ visualizations and Sessions V1 are implemented. The top-level `/programmes`
route is an operational daily active-Program calendar, while
Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. Web Exercises V1, the factual
Dashboard/Analyse read-model foundation, and Sleep Diary V1 are included in
stable v0.1.4.
Dashboard/Analyse read-model foundation, Sleep Diary V1/V2, synchronized
factual heart-rate timelines, and the complete Cardio V1
capture/calibration/guidance chain and the 0.1.6 refinement/recovery work are
included in stable v0.1.6.
The corrective prepared-item projection, bounded phase-owned MTP outbox,
Sessions presentation correction, local date preference and durable preparation
withdrawal are implemented. The withdrawal uses the separate versioned
@ -56,19 +58,48 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
## Current cursor
Stable v0.1.4 closes the Analyse/Sleep Diary cycle and freezes the Bluetooth
Classic synchronization transport independently from future sensor work.
Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization
recovery, and factual session-finalization cycle. Android
alone discovers and reads the heart-rate sensor; desktop/Web consume only
synchronized factual measurements and never connect directly to the sensor.
The heart-rate path remains independent from the frozen Bluetooth Trainlog
synchronization transport.
The next development cycle is 0.1.5 cardio. Android will own heart-rate sensor
discovery/acquisition and timestamped local capture; synchronized desktop/Web
surfaces will consume recorded measurements and will not connect directly to
the sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract.
The stable 0.1.6 Web refinement projects factual Sleep intervals, every
structured awakening, point events, medication intakes, bounds, and duration
through the agenda, selected-night HR detail, and selected-night PDF. Its
explicitly labeled fallback estimate is presentation-only, subtracts overlapping
structured awakenings for displayed duration, and is shared by all three views;
the agenda retains an exact hourly 18:00-to-18:00 geometry. Android lifecycle/ACK regressions now explicitly guard
pre-bed medication snapshots against restart, replay and stale ancestry. The
release closes this implementation cycle without reopening its frozen
contracts.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.6 published; open 0.1.7 stabilization after the immutable release tag.
```
Sleep Diary V1 stays frozen/readable while active capable peers require
`sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive
measured-series presentation only, never inferred sleep states or causes.
The vc45 real-device smoke found that selecting Cardio after live capture had
already started on the default Training draft left the durable capture tagged
as `session`, starving Cardio guidance despite fresh global BPM. Repository
persistence now reconciles the same capture atomically while retaining its
identity, sample order and raw BPM/RR facts; automated coverage reproduces the
transition through an active exercise and guidance event. The vc46 re-smoke
then passed with the same capture, preserved pre-transition samples, measured
guidance, and non-mutating Sommeil/Calibration screen checks.
The private phone advanced non-destructively to Android schema v32 on vc47 with
integrity/FK checks clean and preserved business counts. The preceding
tomorrow-session readiness regression proves Program start and provenance
through two ordered occurrences originating in the synchronized Program
payload, four HR ownership positions, finalization, full generation, desktop
import, idempotent replay and correlated ACK. That readiness evidence is now
part of the stable v0.1.5 validation record rather than the active cursor.
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
cursor: complete predecessor state, imported built-in authorization,
cross-platform draft revision identity and resource-bound admission are closed.

View file

@ -3,29 +3,37 @@
## Sleep Diary companion
`trainlog-sleep-diary` version 1 is finalized in stable v0.1.4 with both the
medication catalog and the entry-owned intake snapshots.
medication catalog and the entry-owned intake snapshots. It remains frozen and
readable historical input. The active Sleep exchange requires the separate
`sleep-diary-v2` capability and `sleep-diary-v2.json` with
`format=trainlog-sleep-diary`, `version=2`.
The artifact preserves catalog and diary causal parents, rejects concurrent
siblings and resurrection, and treats an exact revision replay as idempotent.
This completion happened before publication or freezing of the companion;
`TRAINLOG_FORMAT_V1` remains unchanged and frozen.
Every field edit commits locally as a new immutable diary revision but does
not itself enter a generation. Explicit day validation marks only the guarded
current tip publishable and starts no transport. Generation capture exports
validated current tips, records each exact generation/revision relation, and
only a correlated consumed ACK advances the acknowledged marker. A correction
after ACK therefore remains local and appears as modified until it is
revalidated and acknowledged by a later generation. Imported tips are already
validated and acknowledged because their containing generation was durably
consumed. Draft and synchronized state are not inferred from wall-clock time.
Every field edit commits locally as a new immutable diary revision. Explicit
day validation marks only the guarded current tip publishable and starts no
transport. V2 also permits the current revision of a quick-captured pending
night to be marked quick-publishable: this is a distinct exact-revision marker,
not day validation, and it does not validate an incomplete night. Generation
capture exports either eligible current tip, records each exact
generation/revision relation, and only a correlated consumed ACK advances the
acknowledged marker. A correction after ACK therefore remains local and
appears as modified until it is revalidated or quick-published again and
acknowledged by a later generation. Imported tips are already validated and
acknowledged because their containing generation was durably consumed. Draft
and synchronized state are not inferred from wall-clock time.
`trainlog-sleep-diary`, version 1, is emitted by full generations in stable
v0.1.4. It is optional when consuming an older generation so a pre-domain
artifact remains replayable. It carries bounded complete entry snapshots with
`trainlog-sleep-diary`, version 2, is the active full-generation companion.
It carries bounded complete entry snapshots with
stable identities, the current immutable revision and causal parent,
offset-bearing timestamps, appreciations, notes and logical deletion. Exact
revision replay is unchanged; sibling revisions and resurrection after deletion
are rejected in the generation consumption transaction.
offset-bearing timestamps, appreciations, notes, logical deletion, and an
integer `quantity` in `1..99` on every medication intake. `dose_value` and
`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes.
V1 input omits `quantity` and is normalized to one unit. Exact revision replay
is unchanged; sibling revisions and resurrection after deletion are rejected
in the generation consumption transaction.
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
ownership.
@ -945,6 +953,20 @@ active. Archive directories are outside bounded MTP polling and are never
uploaded recursively. Failure or interruption leaves admission closed rather
than fabricating an ACK or discarding evidence.
An unacknowledged generation whose parent also has a distinct, ledger-backed
acknowledged child is a retained superseded branch: it stays available for
audit and is never deleted or assigned a fabricated ACK, but it no longer
consumes active admission because it cannot become the causal tip. After a
transport reset, the next run republishes the newest unsuperseded immutable
generation for that peer before attempting a new capture. The mutable
generation reference uses the new conversation run; generation ID, manifest,
artifact bytes and eventual ACK correlation remain unchanged. Because the
immutable manifest and its durable ACK retain the original run identity, a
cross-run retry recognizes that ACK by the exact generation ID and manifest
digest; the producer's SQLite acknowledgement validator still verifies the
complete peer, result, durability and original-run contract before advancing
state.
Before Android captures for a negotiated `generation-archive-v1` conversation,
the desktop consumer publishes
`desktop-archive-acknowledgements-v1.json`. Its strictly correlated envelope
@ -991,11 +1013,14 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
and retransmission because generation membership lives only in
`sync_causal_publications`.
The foreground Android coordinator correlates both durable desktop ACKs and
desktop generation references by the active run (and the Android generation
for its ACK). Objects retained from an interrupted conversation are ignored
until atomically replaced by matching objects. After both peer ACKs, Android
reports completion directly; it does not enter the legacy receipt wait state.
The foreground Android coordinator correlates desktop generation references
by the active run. It correlates a durable desktop ACK for an ordinary attempt
by the generation and manifest of that run; for a cross-run resume, those same
two immutable identities deliberately recognize the exact ACK whose embedded
run remains the original capture run. Objects retained from an interrupted
conversation are ignored until atomically replaced by matching objects. After
both peer ACKs, Android reports completion directly; it does not enter the
legacy receipt wait state.
The peer publication precedes one fresh
`trainlog-sync-full-generation-request-v1` signal on
@ -1060,7 +1085,114 @@ format. It lists bounded durable desktop AI proposals and relevant execution
drafts as distinct kinds. Completed sessions remain outside this projection,
and finalized or causally deleted execution drafts are excluded.
## 19. Legacy active-protocol limitations
## 19. Heart-rate companion V1
`trainlog-heart-rate` V1 is an optional full-generation participant produced
only by Android. Its logical name is `heart-rate` and its immutable generation
file is `heart-rate-v1.json`. Desktop never produces sensor measurements and
never connects to the heart-rate device.
A normal Android generation includes only stopped captures that have not
already been acknowledged and are not already retained by an outstanding
captured/published/waiting-ACK generation. Capture, sample and RR identities
are respectively `capture_id`, `capture_id + sequence`, and
`capture_id + sequence + rr_index`.
Desktop schema v30 validates and imports the whole companion inside the
containing generation transaction. Exact replay is idempotent; reusing one
stable measurement identity with different content rejects the generation.
Android schema v27 records the exact `capture_id -> generation_id` relation
when the immutable generation is captured. Only a correlated `consumed` ACK
sets those captures' acknowledgement timestamps. Rejected generations do not
advance cardio acknowledgement. Acknowledged captures remain in Android
history but are omitted from normal later publication, preventing a full
night's samples from being retransmitted on every sync.
An acknowledged session capture may later receive one causal
`heart_rate_tail` deletion when recovery establishes an earlier factual
`session.ended_at`. Its target is the exact `capture_id|cutoff`; the predecessor
digest covers the original capture metadata, ordered BPM samples and ordered RR
children. The cutoff is inclusive: only measurements strictly later than it are
deleted, while `capture.ended_at` becomes the cutoff. Because the general
causal-deletions V1 contract is frozen to its seven existing target kinds, the
operation travels in the separate optional
`trainlog-heart-rate-corrections` V1 / `heart-rate-corrections-v1.json`
companion while reusing the durable causal operation/state ledger. No published
or acknowledged generation is rewritten and no ACK is fabricated. The
correction companion is consumed before Heart Rate V1; that importer also
consults durable correction state before comparing or inserting an old
snapshot, so replay cannot resurrect a corrected tail.
Producer lineage selection treats only unarchived acknowledged generations
backed by an exact `consumed` / `sqlite-commit-full` ACK as active tips. This
keeps legacy terminal rows that predate the durable ACK ledger as immutable
audit evidence without letting them block every later publication. A consumer
can retain several historical consumed tips because it cannot observe which
fork the producer archived; the next manifest is valid only when its declared
parent is one of those current tips. A null, old, or unrelated parent remains a
hard rejection, and neither side rewrites fork evidence.
The companion changes neither the frozen Bluetooth Classic RFCOMM transport,
MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
implementation layer.
## 20. Session timeline companion V1
`trainlog-session-timeline` V1 is an optional Android→desktop full-generation
participant. Android records the real execution interval of each stable
`entry_id`; session start/end reuse the same lifecycle identity and timestamps
that become canonical completed history.
Only completed timelines are published. The desktop v31 consumer first proves
that `session_id`, `started_at`, `ended_at`, `entry_id` and `exercise_id`
already match canonical history, then inserts the occurrence timing facts.
Exact replay is a no-op and conflicting reuse of an identity is rejected.
Heart-rate samples use the same active `entry_id` at acquisition time. Samples
between exercise end and the next exercise start retain no fabricated exercise
owner and therefore remain factual rest/transition data.
## 21. Cardio sessions companion V1
`trainlog-cardio-sessions` V1 is an optional Android→desktop
full-generation participant. It carries completed sessions whose canonical
Android `session_kind` is `cardio`. These sessions are deliberately absent
from the frozen `history-v4` artifact.
The companion reuses the same bounded exercise/occurrence/set/continuous fact
shape internally but owns its own envelope and requires every public
`session_type` to be exactly `cardio`. Desktop schema v32 imports those
facts transactionally, stores legacy `session_type=training` only for
compatibility, and stores canonical `session_kind=cardio`.
Exact replay is idempotent. Reusing a cardio `session_id` that already belongs
to non-cardio canonical history is rejected. The companion is consumed before
session-timeline and heart-rate participants in the same generation, so the
stable `session_id` and occurrence facts exist before their timing and sensor
facts are applied.
Programs, manual preparations, AI execution drafts, mobile V3/V4 and
`TRAINLOG_FORMAT_V1` are not widened by this companion.
## 22. Cardio calibration companion V1
`trainlog-cardio-calibrations` V1 is an optional Android→desktop full-generation participant containing only completed calibration profiles. It references an already synchronized cardio session, its reserved stable occurrence, and an immutable stopped Heart Rate V1 capture.
Before import, desktop proves that the referenced session/occurrence and HR capture already exist and match. The declared observed peak must equal a BPM actually present in that capture, and each recovery point must match an exact raw `(observed_at, bpm)` sample. Exact replay is idempotent; stable identity reuse with different metadata or recovery facts is rejected.
Android generation membership is retained until a correlated consumed ACK. Acknowledged profiles remain local history and are not ordinarily republished. No calibration artifact claims a physiological maximum: the persisted value is the highest BPM actually observed in that calibration capture before recovery.
## 23. Cardio guidance companion V1
`trainlog-cardio-guidance` V1 is an optional Android→desktop full-generation participant containing only completed guided-cardio runs. Each run owns ordered phases, the exact target snapshot used at execution time, its exit condition and only the chronological instruction changes (`accelerate`, `maintain`, `slow_down`, `suspended`). Raw BPM/RR remain exclusively in Heart Rate V1.
A calibration-derived target carries `calibration_id`, the observed peak snapshot and the percentages that resolved the BPM bounds. Desktop requires that reference to match an already imported completed calibration. Every non-suspended instruction event carrying BPM must match an exact raw heart-rate sample for the same cardio session and timestamp. Suspended events carry no BPM and therefore cannot silently reuse a stale measurement.
Exact replay is idempotent. Reusing a stable run/phase identity with different target, condition, interval or event history is rejected. Android records exact `run_id -> generation_id` membership; only a correlated consumed ACK marks the run acknowledged and excludes it from ordinary future publication.
## 24. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
@ -1156,3 +1288,22 @@ packaged user-session deployment. Before every invocation the bounded combined
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
signal, and non-zero-exit diagnostics therefore describe only the current run
and are retained in the synchronization error/history when the run fails.
## Sleep revision identity collision recovery
A Sleep `revision_id` names one immutable parent plus canonical payload. The
generation consumer accepts the same identity only when both are equal; it
rejects a different parent or payload even if the incoming revision is an
ancestor of the local current tip. Ordinary Sleep edits always use a new
revision identity.
The bounded recovery tool is intentionally narrower than general conflict
resolution. It may re-identify a divergent local tip only after proving that
the tip has no descendant, validation/ACK state, or immutable generation
reference. It retains that branch unchanged, restores the manifest-owned peer
revision under its published identity, and creates a fresh single-parent
successor containing only non-conflicting scalar additions and exact-ID unions
of events and medication occurrences. Conflicting non-null scalars, notes, or
same occurrence IDs with different payloads require a human decision. The
generation manifest and artifacts are never rewritten; only an exact rejected
consumer/producer ledger may be rearmed for normal replay.

View file

@ -1,5 +1,86 @@
# Tests and validation
## Sleep revision collision recovery
Collision regressions distinguish exact replay from same-ID parent or payload
reuse, validate known historical ancestors before stale-snapshot handling, and
exercise safe re-identification of an unpublished branch. They preserve
quantity one and two, distinct same-time medication occurrences, event and
intake unions, rollback, immutable-generation references, exact ledger rearm,
and idempotent replay. SQLite migration tests prove that Desktop v36 and
Android v33 reject direct update/delete of all revision-owned Sleep payload
tables. Android also creates a 65-revision edit chain and verifies unique UUIDs,
exact parentage, and unchanged historical payloads.
The retained real-data simulation replays the original immutable full
generation against isolated copies and verifies one session ending at 11:16,
7 exercises, 15 sets, 5 feedbacks, 7 exercise intervals, 4,919 BPM samples,
4,919 RR intervals, Program provenance, `consumed` / `sqlite-commit-full`, a
byte-identical ACK replay, and clean integrity/foreign-key checks. A later
normal generation was consumed and acknowledged, proving continued pipeline
use. Sensitive medication names and dose values remain outside repository
evidence.
## Web UI refinement 0.1.6
Web regressions cover calendar-week activity grouping, global rather than
single-exercise Dashboard progression, exercises-per-date overview data,
measured-only group drill-down, normalized measurement selection, the shared
BODY ZONES figure, persistent language/settings navigation, multi-night Sleep
selection invariants, and Equipment response validation. Native equipment
coverage exercises a synthetic custom duplicate and proves that a transactional
merge repoints completed history to the retained canonical identity without
touching real user equipment. Strict C compilation, the full Meson inventory,
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
diff checks remain the closeout gates.
Post-review regressions cover a validated Sleep Diary revision plus nocturnal
BPM/RR capture through the production full-generation transport, desktop
import, correlated ACK, exact ACK replay, a second Sleep-only revision and a
second generation. The 24-conversation deployed-capacity regression removes
legacy ACK-ledger rows deliberately, recovers capacity without deleting
generation evidence, and continues through archive/replay. Native Web tests
prove that an active zero-occurrence execution draft is deleted causally and
that repeating its request is byte-identical, while a populated active draft
still conflicts. The equipment client test removes `crypto.randomUUID`, uses
deterministic `getRandomValues` bytes, and completes the protected merge HTTP
request; native merge tests retain their transaction/rollback coverage.
The real Sleep Bluetooth recovery regression starts with no Android session
and an empty pending Sleep revision, records two pre-bed medication intakes
(including `quantity=2`), reopens the repository after each meaningful
lifecycle transition, then records Couché, two BPM/RR samples
separated by Réveil, and Levé. It captures and publishes the production V2
Sleep and V1 heart-rate artifacts, imports them with the desktop consumer,
accepts and replays the ACK without duplication, then admits a second
Sleep-only revision. It also applies the original intake-free ancestor after
ACK and proves that causal reconciliation cannot erase the current intakes.
Separate tests prove connection-arrival auto-request
policy with its cooldown, cross-run reuse of an unacknowledged immutable
generation and recognition of its original-run exact generation/digest ACK,
ledger-backed superseded-branch capacity, fresh-peer non-root Sleep ancestry,
service-installer upgrade behavior, and ACK-recovery progress frames over the
Bluetooth-selected orchestrator path.
UI regressions assert the Sleep agenda's 25 exact hourly positions, 13 visible
two-hour desktop labels and five narrow six-hour anchors, including the exact
0/25/50/75/100% coordinates. They also prove that both Sleep views reuse the
same presentation-only Couché +45 minutes to Levé -10 minutes estimate, that a
factual sleep interval wins, and that a missing bound produces no estimate.
Other UI regressions cover persisted
Dashboard sizing and reset behavior, English preference persistence across a
remount, centralized Dashboard English labels, non-stretched chart aspect
ratios, and distinct missing/partial exercise-timing messages.
The current closeout run passes Web **32 files / 209 tests**, TypeScript
typecheck and the production build; native Meson **113/113**; Android **315
tests** with five optional skips plus `assembleDebug`; JSON/import validators;
changed-C formatting; and diff checks.
The restarted private service serves the rebuilt executable byte-for-byte from
the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating
Firefox smoke on the real 2026-09-23 night confirms the agenda and detail both
show 22:50–04:04 as estimated, with 13 ordinary-desktop labels, 25 wide labels
and five narrow anchors.
Sleep Diary coverage includes schema creation/migration, midnight and DST
offsets, revision conflict, logical deletion/reopen, companion replay and
rollback, Web periods/deep links, Android persistence, and vector PDF
@ -21,6 +102,17 @@ execution and verifies Web `1 / 24` presentation. Related regressions cover
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
non-root current revisions.
Android session-finalization coverage includes a Program-derived mixed session
whose continuous target is completed from its measured timeline, several prior
occurrences, feedback, an active heart-rate capture, and a planned `2 x 10`
occurrence performed as three valid `10 @ 23 kg` sets. It recreates the legacy
state with a closed continuous timeline but no performed row, finalizes at an
explicit factual timestamp, and proves that plan, actual sets, feedback,
timeline, BPM samples, and RR intervals remain distinct and intact. Desktop
Sleep exchange coverage also verifies that replay of an identical durable but
unpublished revision acknowledges it without `StopIteration`, while different
content under the same revision identity remains an explicit conflict.
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
@ -269,6 +361,18 @@ cover exact 18:00-to-18:00 timestamp geometry, active-night isolation, empty
night state, return navigation and rejection of an earlier date response that
finishes late. Localized dose/unit labels, same-name medications with different
usual doses and editable intake-dose prefill remain covered.
Selection regressions additionally prove that one agenda click updates both
detail dates and HR identity without a write, and that preview and download
receive the same one-night subset with recomputed observations. Projection
unit tests cover exact factual duration, fallback duration, multiple structured
awakenings, and subtraction of only their overlap from an estimated range.
The heart-rate panel suite additionally covers a complete 22:05-to-04:14 night,
declared and fallback-estimated sleep ranges, multiple close awakenings, final
get-up, pre-bed and close medication intakes with exact dose/unit/quantity,
every intervening hour tick, and a no-HR rendering that creates no measured
curve. Geometry tests prove exact factual bounds and the common normalized
projection used by chart samples, overlays, events, intakes, and ticks; narrow
viewports preserve that geometry in a horizontally scrollable chart.
`test_web_sleep_browser.py` exercises the embedded production bundle in real
Firefox against an isolated synthetic database, observes every mutation
without reload, verifies HTTP success and browser error collectors, measures
@ -277,7 +381,9 @@ empty night and back, then reloads and compares the persisted Agenda.
The PDF suite separately asserts complete French and English vocabularies,
accent-preserving WinAnsi text, human-readable duration and plural forms,
localized dates, medication dose snapshots, pagination and the exact ordered
geometry of sleep / 45-minute long-awakening / sleep intervals. The same real
geometry of sleep / 45-minute long-awakening / sleep intervals. PDF rows reuse
the same projected sleep/awake/point/medication primitives as agenda and HR;
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.
@ -1648,3 +1754,336 @@ checks passed, both outgoing desktop generations were acknowledged, and the
Android UI displayed completion. The private journal retains exact IDs and
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
was used only for non-destructive install, backup and manual sync validation.
## Cardio persistence/synchronization V1 validation
`TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS` is covered without requiring a
physical heart-rate sensor. Android migration coverage advances schema v26 to
v27 and verifies durable raw BPM/contact/energy/RR capture; desktop migration
coverage advances v29 to v30 and verifies the additive cardio tables/indexes.
`heart_rate_exchange` validates the strict `trainlog-heart-rate` V1 envelope,
cardio/session/sleep ownership, timestamp bounds, raw RR preservation, exact
idempotent replay, and rejection of identity reuse with different measured
content. The Android full-generation regression captures a stopped sleep-owned
measurement, publishes it, consumes it through the production desktop
generation tool, accepts the correlated ACK, verifies one desktop
capture/sample/RR row, and proves the acknowledged Android capture is no longer
emitted.
`test_heart_rate_tail_recovery` covers the delayed-session correction boundary:
a sample exactly at the factual end remains, the first representable sample
after it and its RR child are removed, statistics use only retained facts, an
already bounded capture is an idempotent no-op, wrong session/capture and
invalid temporal bounds are refused, and an injected post-effect failure rolls
back every row. It then replays the old Heart Rate V1 snapshot and the same
causal operation twice to prove non-resurrection and second-sync idempotence.
It also proves that `heart_rate_tail` appears only in the separately versioned
heart-rate-correction artifact and remains absent from frozen
causal-deletions V1.
`TrainlogRepositoryDraftTest.delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively`
exercises the production Android delayed-finalization transaction.
The 2026-09-24 paired-device closeout applies operation
`hrx_a2222757-9a85-492f-823b-ad590208ce6b` to capture
`hrc_fe0627e0-dff6-4629-82d9-03da78c49634`. Android and desktop both report
3,429 BPM samples, 3,429 RR values, no sample after 11:16, last sample
11:15:59.759323, and 89 / 119.30300379119277 / 138 BPM. Full MTP runs
`sy_d1a29dd9-7b51-41db-83e1-5712758b3700` and
`sy_4b8b3c28-17b1-4300-bfbf-9332385f69ab` both completed through
`peer_consumed`. A production import of the retained pre-correction 4,919-point
artifact left the same counts and statistics. The live Web history detail in
Firefox rendered the 10:18--11:16 curve, seven exercises, 15 sets, 3,429 RR,
and the same min/average/max values.
Generation regressions additionally cover an archived acknowledged fork, a
legacy acknowledged row without durable ACK evidence, and consumer acceptance
of a producer-selected current fork tip while rejecting stale/null ancestry.
Android generation tests exercise the matching rules. Sleep quick-capture and
generation tests cover the schema-v33 medication-intake column order with
quantity 2, preventing note/creation-time values from being bound to the
`quantity` constraint.
The final post-device closeout passes the complete native Meson inventory
**114/114**, the complete clang ASan/UBSan Meson inventory **114/114**, and
Android `testDebugUnitTest` with **317 tests: 312 passed, five skipped, zero
failures/errors**. Android `assembleDebug`, both JSON/import validators,
changed-file C formatting, and `git diff --check` also pass.
The 2026-09-23 closeout passes the complete native Meson inventory:
**102/102 passed, 0 failed**. The complete Android JVM inventory reports
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
remain optional historical/external fixture gates and are not Cardio V1
failures.
## Cardio BLE diagnostics V1 validation
`TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`
keeps BLE protocol parsing separate from Android GATT transport. Unit coverage
exercises 8-bit and 16-bit BPM, nullable sensor-contact semantics, cumulative
energy, one/multiple raw RR intervals, combined flags, boundary BPM values,
reserved flags, missing fields, truncated RR/energy values and unexpected
trailing bytes.
The Android diagnostic uses a 10-second Heart Rate Service-filtered scan with
an explicit expanded BLE fallback, then discovers `0x180D`, locates `0x2A37`,
checks optional Battery Service and enables the CCC notification descriptor.
It is screen-scoped and does not implement the later persistent foreground
acquisition owner.
On 2026-09-23 the diagnostic APK was installed non-destructively on the
authorized SM_G990B as Trainlog 0.1.5 versionCode 38 using the same Android
debug signing certificate as the existing private installation. Application
startup completed without SQLite/crash errors and the diagnostic route opened.
The first real 10-second HRS-filtered scan completed cleanly before the armband
was positively identified. A subsequent expanded scan identified the real
CYCPLUS H2 advertising Heart Rate Service. Trainlog connected through GATT,
discovered `0x180D`, `0x2A37` and Battery Service, enabled notifications and
received live BPM successfully. The observed payloads carried one raw RR
interval, no sensor-contact field and no energy-expended field. A 12-second
measurement window advanced from 43 to 56 notifications (13 notifications),
confirming an approximately 1 Hz emission cadence. This closes the real-sensor
hardware characterization gate without recording device-specific MAC identity
in project documentation.
The complete Android JVM inventory after this tranche reports **266 tests:
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
## Cardio persistent acquisition V1 validation
`TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS` adds a remembered Android
heart-rate sensor, a dedicated connected-device foreground service, process-wide
live state and the global app-bar `♥ BPM` indicator. A fresh measurement is
green; connection/reconnection or stale signal is orange; no configured/active
sensor is grey. A BPM older than three seconds is removed from presentation,
and a stale GATT stream is forcibly recycled after eight seconds.
Unit coverage proves durable sensor selection/clear and the live-state
transition from connecting to measured BPM/RR, stale `-- BPM`, and
disconnected. The complete Android JVM inventory after this tranche reports
**268 tests: 263 passed, five skipped, zero failures/errors**; `assembleDebug`
also passes.
Real-device validation used the authorized CYCPLUS H2 on the private Trainlog
0.1.5 versionCode 40 installation. With no selected sensor the global indicator
rendered `♥ -- BPM` and the disconnected accessibility state. Selecting the
validated sensor produced live `♥ 75 BPM`; a complete app force-stop/restart
reloaded the remembered sensor and returned automatically at `♥ 72 BPM`.
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
reenabling Bluetooth restored the same foreground service automatically and
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.
## Training timeline V1 validation
`TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS` covers Android schema v28 and
desktop schema v31. Repository tests prove explicit exercise start/end
timestamps, one-active-exercise enforcement, restart-safe active ownership,
finalization refusal while an exercise remains active, and heart-rate samples
tagged with the active stable `entry_id` while rest samples remain unowned.
The generation regression publishes a completed timeline through the production
full-generation path, imports it through `trainlog-session-timeline` V1,
accepts the correlated ACK, and verifies idempotent replay/conflict rejection.
The 2026-09-23 closeout passes the complete native Meson inventory:
**104/104 passed, 0 failed**. The complete Android JVM inventory reports
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
also succeeds. The five skips remain optional historical/external fixture gates.
## Android one-tap Sleep V1 validation
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
API creates/advances normal immutable Sleep Diary revisions for Couché,
medication intake, 30-minute structured Réveil and Levé actions. Undo is accepted only when
the action's exact applied revision is still current; older receipts conflict,
and successful undo appends a compensating revision instead of rewriting the
history.
Targeted coverage executes Couché, a configured medication with dose snapshot,
two independent awakening intervals, live sleep-owned BPM/RR capture, Levé and final
heart-rate export against the same stable sleep `entry_id`. A separate test
proves stale undo rejection and exact restoration of the latest action. A
dedicated regression proves that a second tap inside 30 minutes extends one
interval and that final get-up clamps it without leaving overlapping windows.
The complete Android JVM inventory before the final localization-only cleanup
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
`assembleDebug` succeeded. After the localization cleanup the targeted
SleepQuickCapture test and `assembleDebug` passed again.
The 30-minute awakening-policy follow-up passes the current complete inventory:
**312 tests: 307 passed, five skipped, zero failures/errors**, plus
`assembleDebug`. Real-data Firefox coverage additionally asserts two orange
agenda/HR bands and two orange vector-PDF intervals for the repaired
2026-09-23 night.
The subsequent agenda/report-range regression covers exact 18:00-to-18:00
hour geometry, a visible 24-column row grid, inclusive start/end filtering,
identical preview/export snapshots, range summary recomputation, and the
independence of the selected HR/editor night from the PDF range.
The private Android installation was advanced non-destructively to Trainlog
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
data clear. The device locked before the final visual Sleep-screen inspection,
so no lock bypass was attempted and no real Sleep business button was pressed;
that visual review remains explicitly pending.
## Cardio session type V1 validation
`TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS` covers the new canonical session kind
without widening frozen historical codecs. Android schema v29 migration tests
prove that v28 training/max-test rows backfill from the legacy
`session_type`, including a structurally adversarial retained-column fixture.
A product-level Android regression creates, reloads and finalizes a real
`SessionType.CARDIO` draft, verifies legacy `session_type=training` plus
canonical `session_kind=cardio`, and proves the cardio session is absent from
`history-v4`.
The end-to-end generation regression proves the complementary path:
`history-v4` contains zero cardio sessions, `cardio-sessions-v1.json`
contains the completed cardio session, the production desktop generation
consumer imports it as legacy training/canonical cardio with its performed set,
the correlated ACK is accepted, and exact replay remains idempotent.
Desktop schema v32 migration coverage validates the additive canonical column,
historical-layout tolerance and foreign-key integrity.
`cardio_session_exchange` separately covers strict cardio-only validation,
exact replay and rejection of a stable identity colliding with non-cardio
history.
The 2026-09-23 closeout passes the complete native Meson inventory:
**106/106 passed, 0 failed**. The complete Android JVM inventory reports
**277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug`
also succeeds. The five skips remain optional historical/external fixture gates.
## Cardio calibration V1 validation
`TRAINLOG_CARDIO_CALIBRATION_V1=PASS` covers the Android v29→v30 and desktop v32→v33 additive migrations, reserved system exercise ownership, real cardio session/timeline/HR capture integration, observed peak tracking, recovery, exact replay and generation synchronization.
The calibration system exercise keeps a stable `exercise_id` for occurrence foreign keys but is deliberately absent from the ordinary exercise catalogue, BODY ZONES, profile-state and PC-catalog companions. This preserves existing exercise-domain invariants while the dedicated cardio session/calibration companions own the system occurrence.
Recovery cannot complete before three minutes after effort end. The +60/+120/+180 second references accept only a real HR sample arriving from the target instant through ten seconds after it. A missing sensor interval remains missing rather than being populated by a later reconnect.
The desktop importer independently rejects a peak not supported by the raw HR capture and rejects a recovery point that is not an exact raw sample. The 2026-09-23 closeout passes the complete native inventory **108/108** and the complete Android JVM inventory **281 tests: 276 passed, five skipped, zero failures/errors**; `assembleDebug` also succeeds.
## Cardio BPM guidance V1 validation
`TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS` covers Android schema v31, desktop
schema v34, pure guidance logic, persistent phase/run history, live CYCPLUS
integration, strict synchronization and the Android active-session UI.
The guidance engine resolves either absolute BPM targets or calibration-relative
percentages into an immutable BPM snapshot before execution. The default
control-stability parameters are a 2 BPM hysteresis band and a 3 second
confirmation interval; these are UI/control parameters, not physiological zone
claims. A stale or missing heart-rate signal suspends guidance immediately and
never reuses the last BPM.
Supported phase exit conditions are fixed duration, entry into the resolved BPM
target, recovery below a threshold, and duration-or-recovery. Only time-based
conditions can complete without a fresh BPM measurement.
Android records ordered phases against the active stable cardio `entry_id`,
persists target/calibration provenance and stores only instruction changes.
The full-generation regression proves a completed run with raw BPM evidence is
imported through `trainlog-cardio-guidance` V1, receives a correlated consumed
ACK, and is excluded from the next ordinary Android publication. Desktop
independently verifies canonical cardio session ownership, exercise timeline,
calibration provenance and exact raw BPM evidence for non-suspended events.
The 2026-09-23 closeout passes the complete native Meson inventory
**110/110**, and the complete Android JVM inventory reports **300 tests:
295 passed, five skipped, zero failures/errors**. `assembleDebug` succeeds.
## Cardio Web visualization V1 validation
`TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS` adds one bounded read-only C17
heart-rate timeline projection and one loopback GET endpoint keyed only by the
stable session/cardio `se_` or Sleep Diary `sl_` identity. The projection
returns synchronized raw BPM samples with native RR arrays, canonical
session/exercise or Sleep/medication markers, guided-cardio phase snapshots and
instruction changes, and factual calibration peak/recovery data when present.
It performs no sleep-stage, return-to-sleep, HRV, readiness or medical
inference.
Web History and the Sleep workspace reuse the same React timeline component.
Min/max/mean facts are calculated over every returned BPM sample. Only the SVG
projection is bounded for large captures by retaining bucket extrema; the
source samples and statistics are not downsampled. A large synchronized night
therefore remains exact at the data boundary without forcing a 100,000-point
SVG path.
The backend test covers cardio BPM/RR, exercise intervals, guidance, calibration,
Sleep events, medication points, empty contexts and invalid identities. The HTTP
test covers valid empty GET, bad context rejection and GET-only semantics. The
2026-09-23 closeout passes the complete native inventory **111/111** and the
complete Web inventory **29 files / 183 tests**; Web typecheck and production
build also succeed.
## Trainlog 0.1.5 complete validation
`TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING` closes the
complete software regression after Cardio Web visualization. The native Meson
inventory passes **111/111**. Android reports **300 JVM tests: 295 passed, five
historical/external skips, zero failures/errors**, and `assembleDebug`
succeeds. Web passes **29 files / 183 tests**, TypeScript typecheck and the
production Vite build.
The authorized SM_G990B was backed up before the final private candidate. The
pre-update `trainlog-android.db` was copied while the app was force-stopped and
verified at 1,892,352 bytes. Private Trainlog 0.1.5 advanced in place from
versionCode 42 to versionCode 43 using the exact same Android debug signing
certificate; `adb install -r` succeeded without uninstall or data clear.
Before and after vc43, the Android store remained schema v31 with identical
high-level business counts: 12 completed sessions, 45 catalogue exercises and
2 Sleep Diary entries. The four cardio-guidance tables are present after the
update, `PRAGMA integrity_check` returns `ok`, and
`PRAGMA foreign_key_check` returns no row.
The locked-device background smoke test confirms both Trainlog foreground
services remain alive. `HeartRateSensorService` continued receiving the real
CYCPLUS H2 and exposed 94 then 95 BPM through its foreground notification while
the phone was dozing, proving the final candidate still acquires live heart
rate without requiring the activity to remain visible.
The phone was locked during the final UI probe, so the visual smoke test of
Sommeil, Calibration cardio and active guided-cardio presentation remains
`DEVICE_VISUAL_PENDING`. No stable `v0.1.5` tag or release is authorized by
this closeout.
## Sleep tonight readiness V2 validation
The validated development delta retains frozen Sleep Diary V1 input and tests
the active V2 companion separately. Android migration coverage advances schema
v31 to v32 and proves the additive `quantity` column defaults historical
intakes to `1`, while the separate quick-publication state does not mark a day
validated. Desktop migration coverage advances v34 to v35 with the same
bounded `1..99` quantity contract.
Focused Android repository/service coverage proves the full factual lifecycle:
pre-bed medication creates one durable pending 18:00-to-18:00 entry; additional
intakes reuse it; Couché adds `BED_TIME` to that same stable entry; and a
quantity of two remains distinct from the per-unit dose. It also proves
pre-bed medication opens no HR capture, the first real post-bedtime CYCPLUS
sample opens/reuses one sleep capture at factual bedtime, Réveil leaves it
open, Levé closes it at factual final get-up, and a later sample cannot append.
The no-sensor path proves Couché through Levé creates neither capture nor
sample.
Generation/import coverage proves a pending entry can synchronize without a
fabricated `BED_TIME`, the later bedtime revision advances the same identity,
quantity round-trips exactly, V1 intake input reads as quantity one, and replay
remains idempotent. The Web suite covers all period nights, row selection and
the selected full-width Sleep HR detail, shared 18:00-axis geometry, quantity
editing/presentation and PDF `×N` presentation. HR variation unit coverage
includes stable, rise, fall, insufficient-baseline, short-capture, gap and
boundary cases.
The reported validation passes the complete Meson inventory **111/111**, both
import-contract checks **6/6**, Android `testDebugUnitTest` and `assembleDebug`,
and the complete Web inventory **191/191** with typecheck and production build.
JSON validators, changed-file formatting, and `git diff --check` also pass.
This is software evidence only: it records no device rollout and authorizes no
stable release.

View file

@ -1,7 +1,7 @@
project(
'trainlog',
'c',
version: '0.1.4',
version: '0.1.6',
meson_version: '>=1.3.0',
default_options: [
'c_std=c17',

View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
import subprocess
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import cardio_calibration_exchange as calibration
from trainlog_sqlite import connect_database
CAL = "cal_11111111-1111-4111-8111-111111111111"
SESSION = "se_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
CAPTURE = "hrc_44444444-4444-4444-8444-444444444444"
EXERCISE = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
def document() -> dict:
return {
"format": "trainlog-cardio-calibrations",
"version": 1,
"generated_at": "2026-09-23T10:07:00+02:00",
"calibrations": [
{
"calibration_id": CAL,
"protocol_version": 1,
"session_id": SESSION,
"entry_id": ENTRY,
"started_at": "2026-09-23T10:00:00+02:00",
"effort_end_at": "2026-09-23T10:03:01+02:00",
"ended_at": "2026-09-23T10:06:10+02:00",
"heart_rate_capture_id": CAPTURE,
"observed_peak_bpm": 160,
"recovery": [
{
"target_offset_seconds": 60,
"observed_at": "2026-09-23T10:04:01+02:00",
"bpm": 140,
},
{
"target_offset_seconds": 120,
"observed_at": "2026-09-23T10:05:02+02:00",
"bpm": 120,
},
{
"target_offset_seconds": 180,
"observed_at": "2026-09-23T10:06:01+02:00",
"bpm": 100,
},
],
}
],
}
class CardioCalibrationExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
fixture = ROOT / "build/tui/sync-generation-fixture"
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
self.db = connect_database(self.db_path)
self.db.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) "
"VALUES(?,?,?,?,?,?)",
(EXERCISE, "Calibration cardio", "calibration cardio", "continuous", "duration", 0),
)
exercise_row = self.db.execute(
"SELECT id FROM exercises WHERE exercise_id=?", (EXERCISE,)
).fetchone()[0]
self.db.execute(
"INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind) "
"VALUES(?,?,?,?,?)",
(
SESSION,
"2026-09-23T10:00:00+02:00",
"2026-09-23T10:06:10+02:00",
"training",
"cardio",
),
)
session_row = self.db.execute(
"SELECT id FROM sessions WHERE session_id=?", (SESSION,)
).fetchone()[0]
self.db.execute(
"INSERT INTO session_exercises("
"entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,"
"data_fields,position,load_mode,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?)",
(ENTRY, session_row, exercise_row, "continuous", "duration", 0, 0, "none", 0),
)
self.db.execute(
"INSERT INTO heart_rate_captures("
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
") VALUES(?,?,?,?,?,?,?)",
(
CAPTURE,
"cardio",
SESSION,
"2026-09-23T10:00:00+02:00",
"2026-09-23T10:06:10+02:00",
"CYCPLUS H2",
"2026-09-23T10:07:00+02:00",
),
)
samples = [
("2026-09-23T10:00:10+02:00", 80),
("2026-09-23T10:01:20+02:00", 112),
("2026-09-23T10:02:30+02:00", 150),
("2026-09-23T10:03:00+02:00", 160),
("2026-09-23T10:04:01+02:00", 140),
("2026-09-23T10:05:02+02:00", 120),
("2026-09-23T10:06:01+02:00", 100),
]
self.db.executemany(
"INSERT INTO heart_rate_samples("
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
[
(CAPTURE, index, observed_at, bpm, ENTRY, None, None)
for index, (observed_at, bpm) in enumerate(samples)
],
)
self.db.commit()
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay_are_factual_and_idempotent(self):
payload = calibration.validate(document())
self.assertEqual((1, 0), calibration.apply(self.db, payload))
self.assertEqual((0, 1), calibration.apply(self.db, payload))
self.assertEqual(
(1, SESSION, CAPTURE, 160),
tuple(
self.db.execute(
"SELECT protocol_version,session_id,heart_rate_capture_id,observed_peak_bpm "
"FROM cardio_calibrations WHERE calibration_id=?",
(CAL,),
).fetchone()
),
)
self.assertEqual(
[(60, 140), (120, 120), (180, 100)],
[
tuple(row)
for row in self.db.execute(
"SELECT target_offset_seconds,bpm FROM cardio_calibration_recovery "
"WHERE calibration_id=? ORDER BY target_offset_seconds",
(CAL,),
)
],
)
def test_rejects_peak_not_supported_by_raw_capture(self):
changed = deepcopy(document())
changed["calibrations"][0]["observed_peak_bpm"] = 159
with self.assertRaisesRegex(ValueError, "peak BPM"):
calibration.apply(self.db, calibration.validate(changed))
def test_rejects_recovery_point_not_present_in_raw_capture(self):
changed = deepcopy(document())
changed["calibrations"][0]["recovery"][0]["bpm"] = 139
with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"):
calibration.apply(self.db, calibration.validate(changed))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,236 @@
#!/usr/bin/env python3
import sys
import tempfile
import subprocess
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import cardio_guidance_exchange as guidance
from trainlog_sqlite import connect_database
RUN = "cgr_11111111-1111-4111-8111-111111111111"
PHASE = "cgp_22222222-2222-4222-8222-222222222222"
SESSION = "se_33333333-3333-4333-8333-333333333333"
ENTRY = "sxe_44444444-4444-4444-8444-444444444444"
EXERCISE = "ex_55555555-5555-4555-8555-555555555555"
CAPTURE = "hrc_66666666-6666-4666-8666-666666666666"
CAL = "cal_77777777-7777-4777-8777-777777777777"
def document(calibrated: bool = False) -> dict:
target = {
"minimum_bpm": 120,
"maximum_bpm": 140,
"calibration_id": CAL if calibrated else None,
"calibration_observed_peak_bpm": 160 if calibrated else None,
"minimum_percent": 75 if calibrated else None,
"maximum_percent": 88 if calibrated else None,
}
return {
"format": "trainlog-cardio-guidance",
"version": 1,
"generated_at": "2026-09-23T13:11:00+02:00",
"runs": [
{
"run_id": RUN,
"session_id": SESSION,
"started_at": "2026-09-23T13:00:20+02:00",
"ended_at": "2026-09-23T13:03:20+02:00",
"phases": [
{
"phase_id": PHASE,
"entry_id": ENTRY,
"position": 0,
"kind": "work",
"target": target,
"exit_condition": {
"kind": "fixed_duration",
"seconds": 180,
"bpm": None,
},
"started_at": "2026-09-23T13:00:20+02:00",
"ended_at": "2026-09-23T13:03:20+02:00",
"final_instruction": "maintain",
"events": [
{
"sequence": 0,
"observed_at": "2026-09-23T13:00:23+02:00",
"instruction": "accelerate",
"bpm": 110,
"target_minimum_bpm": 120,
"target_maximum_bpm": 140,
},
{
"sequence": 1,
"observed_at": "2026-09-23T13:00:30+02:00",
"instruction": "suspended",
"bpm": None,
"target_minimum_bpm": 120,
"target_maximum_bpm": 140,
},
{
"sequence": 2,
"observed_at": "2026-09-23T13:00:40+02:00",
"instruction": "maintain",
"bpm": 130,
"target_minimum_bpm": 120,
"target_maximum_bpm": 140,
},
],
}
],
}
],
}
class CardioGuidanceExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
fixture = ROOT / "build/tui/sync-generation-fixture"
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
self.db = connect_database(self.db_path)
self.db.execute(
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) "
"VALUES(?,?,?,?,?,?)",
(EXERCISE, "Tapis guidé", "tapis guide", "continuous", "duration", 0),
)
exercise_row = self.db.execute(
"SELECT id FROM exercises WHERE exercise_id=?", (EXERCISE,)
).fetchone()[0]
self.db.execute(
"INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind) "
"VALUES(?,?,?,?,?)",
(
SESSION,
"2026-09-23T13:00:00+02:00",
"2026-09-23T13:10:10+02:00",
"training",
"cardio",
),
)
session_row = self.db.execute(
"SELECT id FROM sessions WHERE session_id=?", (SESSION,)
).fetchone()[0]
self.db.execute(
"INSERT INTO session_exercises("
"entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,"
"data_fields,position,load_mode,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?)",
(ENTRY, session_row, exercise_row, "continuous", "duration", 0, 0, "none", 0),
)
self.db.execute(
"INSERT INTO session_exercise_timeline("
"session_id,entry_id,exercise_id,started_at,ended_at,imported_at"
") VALUES(?,?,?,?,?,?)",
(
SESSION,
ENTRY,
EXERCISE,
"2026-09-23T13:00:10+02:00",
"2026-09-23T13:10:10+02:00",
"2026-09-23T13:11:00+02:00",
),
)
self.db.execute(
"INSERT INTO heart_rate_captures("
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
") VALUES(?,?,?,?,?,?,?)",
(
CAPTURE,
"cardio",
SESSION,
"2026-09-23T13:00:00+02:00",
"2026-09-23T13:10:10+02:00",
"CYCPLUS H2",
"2026-09-23T13:11:00+02:00",
),
)
self.db.executemany(
"INSERT INTO heart_rate_samples("
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
[
(CAPTURE, 0, "2026-09-23T13:00:23+02:00", 110, ENTRY, None, None),
(CAPTURE, 1, "2026-09-23T13:00:40+02:00", 130, ENTRY, None, None),
],
)
self.db.execute(
"INSERT INTO cardio_calibrations("
"calibration_id,protocol_version,session_id,entry_id,started_at,effort_end_at,"
"ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at"
") VALUES(?,?,?,?,?,?,?,?,?,?)",
(
CAL,
1,
SESSION,
ENTRY,
"2026-09-23T13:00:00+02:00",
"2026-09-23T13:02:00+02:00",
"2026-09-23T13:03:00+02:00",
CAPTURE,
160,
"2026-09-23T13:11:00+02:00",
),
)
self.db.commit()
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay_preserve_snapshot_and_events(self):
payload = guidance.validate(document())
self.assertEqual((1, 0), guidance.apply(self.db, payload))
self.assertEqual((0, 1), guidance.apply(self.db, payload))
self.assertEqual(
(SESSION, "2026-09-23T13:00:20+02:00", "2026-09-23T13:03:20+02:00"),
tuple(
self.db.execute(
"SELECT session_id,started_at,ended_at FROM cardio_guidance_runs WHERE run_id=?",
(RUN,),
).fetchone()
),
)
self.assertEqual(
[(0, "accelerate", 110), (1, "suspended", None), (2, "maintain", 130)],
[
tuple(row)
for row in self.db.execute(
"SELECT sequence,instruction,bpm FROM cardio_guidance_events "
"WHERE run_id=? AND phase_id=? ORDER BY sequence",
(RUN, PHASE),
)
],
)
def test_calibration_provenance_must_match_imported_profile(self):
payload = guidance.validate(document(calibrated=True))
self.assertEqual((1, 0), guidance.apply(self.db, payload))
changed = deepcopy(document(calibrated=True))
changed["runs"][0]["phases"][0]["target"]["calibration_observed_peak_bpm"] = 159
with self.assertRaisesRegex(ValueError, "calibration provenance"):
guidance.apply(self.db, guidance.validate(changed))
def test_guidance_bpm_must_exist_in_raw_capture(self):
changed = deepcopy(document())
changed["runs"][0]["phases"][0]["events"][0]["bpm"] = 109
with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"):
guidance.apply(self.db, guidance.validate(changed))
def test_identity_reuse_with_different_event_is_rejected(self):
payload = guidance.validate(document())
guidance.apply(self.db, payload)
changed = deepcopy(document())
changed["runs"][0]["phases"][0]["final_instruction"] = "slow_down"
with self.assertRaisesRegex(ValueError, "different content"):
guidance.apply(self.db, guidance.validate(changed))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
import json
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import cardio_session_exchange as cardio
from trainlog_sqlite import connect_database
import import_equipment_definitions
SESSION = "se_11111111-1111-4111-8111-111111111111"
EXERCISE = "ex_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
def document() -> dict:
return {
"format": "trainlog-cardio-sessions",
"version": 1,
"generated_at": "2026-09-23T10:00:00+02:00",
"exercises": [
{
"exercise_id": EXERCISE,
"name": "Cardio exchange exercise",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
}
],
"sessions": [
{
"session_id": SESSION,
"started_at": "2026-09-23T09:00:00+02:00",
"ended_at": "2026-09-23T09:30:00+02:00",
"session_type": "cardio",
"note": None,
"exercises": [
{
"exercise_id": EXERCISE,
"name": "Cardio exchange exercise",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
"load_mode": "none",
"rest_seconds": 0,
"entry_id": ENTRY,
"position": 0,
"equipment_id": None,
"target": None,
"note": None,
"sets": [{"reps": 12}],
}
],
}
],
}
class CardioSessionExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
fixture = ROOT / "build/tui/sync-generation-fixture"
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
self.db = connect_database(self.db_path)
self.known_equipment = import_equipment_definitions.supplied_ids(
ROOT / "catalog/equipment-v1.json"
)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay_keep_legacy_type_frozen(self):
payload = cardio.validate(document(), self.known_equipment)
first = cardio.apply(self.db, payload, complete_causal_envelope=True)
second = cardio.apply(self.db, payload, complete_causal_envelope=True)
self.assertEqual(1, first["sessions_imported"])
self.assertEqual(0, second["sessions_imported"])
self.assertEqual(
("training", "cardio"),
tuple(
self.db.execute(
"SELECT session_type,session_kind FROM sessions WHERE session_id=?",
(SESSION,),
).fetchone()
),
)
self.assertEqual(
(ENTRY, 12),
tuple(
self.db.execute(
"SELECT se.entry_id,ps.reps FROM session_exercises se "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?",
(SESSION,),
).fetchone()
),
)
def test_rejects_non_cardio_session_type(self):
changed = document()
changed["sessions"][0]["session_type"] = "training"
with self.assertRaisesRegex(ValueError, "must be cardio"):
cardio.validate(changed, self.known_equipment)
def test_rejects_identity_collision_with_non_cardio_history(self):
payload = cardio.validate(document(), self.known_equipment)
cardio.apply(self.db, payload, complete_causal_envelope=True)
self.db.execute(
"UPDATE sessions SET session_kind='training' WHERE session_id=?",
(SESSION,),
)
with self.assertRaisesRegex(ValueError, "collides with non-cardio"):
cardio.apply(self.db, payload, complete_causal_envelope=True)
if __name__ == "__main__":
unittest.main()

View file

@ -126,7 +126,9 @@ class GenerationArchiveTest(unittest.TestCase):
stage, manifest, _ = generation.capture_desktop(self.database, self.owned, PEER)
self.assertTrue(stage.is_dir())
self.assertEqual(parent, manifest["parent_generation_id"])
# Historical status alone is terminal evidence, not a durable
# consumed ACK and therefore not an active lineage parent.
self.assertIsNone(manifest["parent_generation_id"])
with sqlite3.connect(self.database) as database:
self.assertEqual(
0,

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Heart Rate V1 companion validation and idempotent desktop import tests."""
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import heart_rate_exchange as heart_rate
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
SESSION = "se_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
def schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE heart_rate_captures(
capture_id TEXT PRIMARY KEY,context_kind TEXT,context_id TEXT,
started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
CREATE TABLE heart_rate_samples(
capture_id TEXT,sequence INTEGER,observed_at TEXT,bpm INTEGER,
exercise_entry_id TEXT,sensor_contact_detected INTEGER,
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id));
CREATE TABLE heart_rate_rr_intervals(
capture_id TEXT,sample_sequence INTEGER,rr_index INTEGER,value_1024 INTEGER,
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence));
PRAGMA user_version=30;
"""
)
def document() -> dict:
return {
"format": "trainlog-heart-rate",
"version": 1,
"generated_at": "2026-09-22T19:00:00+02:00",
"captures": [{
"capture_id": CAPTURE,
"context_kind": "session",
"context_id": SESSION,
"started_at": "2026-09-22T18:00:00+02:00",
"ended_at": "2026-09-22T18:30:00+02:00",
"sensor_name": "Synthetic HR",
"samples": [{
"sequence": 0,
"observed_at": "2026-09-22T18:01:00+02:00",
"bpm": 92,
"exercise_entry_id": ENTRY,
"sensor_contact_detected": True,
"energy_expended": 12,
"rr_intervals_1024": [700, 710],
}],
}],
}
class HeartRateExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = sqlite3.connect(Path(self.temp.name) / "heart.sqlite")
schema(self.db)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_exact_replay_and_raw_rr(self):
payload = heart_rate.validate(document())
self.assertEqual((1, 0), heart_rate.apply(self.db, payload))
self.assertEqual((0, 1), heart_rate.apply(self.db, payload))
self.assertEqual(
(92, ENTRY, 1, 12),
self.db.execute(
"SELECT bpm,exercise_entry_id,sensor_contact_detected,energy_expended "
"FROM heart_rate_samples"
).fetchone(),
)
self.assertEqual(
[(700,), (710,)],
self.db.execute(
"SELECT value_1024 FROM heart_rate_rr_intervals ORDER BY rr_index"
).fetchall(),
)
def test_same_identity_with_different_measurement_is_rejected(self):
payload = heart_rate.validate(document())
heart_rate.apply(self.db, payload)
changed = document()
changed["captures"][0]["samples"][0]["bpm"] = 93
with self.assertRaisesRegex(ValueError, "different content"):
heart_rate.apply(self.db, heart_rate.validate(changed))
def test_cardio_context_accepts_session_identity_and_exercise_marker(self):
cardio = document()
cardio["captures"][0]["context_kind"] = "cardio"
validated = heart_rate.validate(cardio)
self.assertEqual("cardio", validated["captures"][0]["context_kind"])
self.assertEqual(ENTRY, validated["captures"][0]["samples"][0]["exercise_entry_id"])
def test_rejects_sample_outside_capture_and_sleep_exercise_marker(self):
outside = document()
outside["captures"][0]["samples"][0]["observed_at"] = "2026-09-22T17:59:59+02:00"
with self.assertRaisesRegex(ValueError, "outside capture"):
heart_rate.validate(outside)
sleep = document()
capture = sleep["captures"][0]
capture["context_kind"] = "sleep"
capture["context_id"] = "sl_44444444-4444-4444-8444-444444444444"
with self.assertRaisesRegex(ValueError, "exercise context"):
heart_rate.validate(sleep)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Causal recovery coverage for delayed session-end heart-rate tails."""
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import heart_rate_exchange
import causal_delete_exchange
import heart_rate_correction_exchange
import recover_heart_rate_tail as recovery
SESSION = "se_22222222-2222-4222-8222-222222222222"
OTHER_SESSION = "se_99999999-9999-4999-8999-999999999999"
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
OTHER_CAPTURE = "hrc_88888888-8888-4888-8888-888888888888"
CUTOFF = "2026-09-24T11:16:00+02:00"
def create_schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,
started_at TEXT,ended_at TEXT,session_type TEXT,notes TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER);
CREATE TABLE continuous_activity(session_exercise_row_id INTEGER PRIMARY KEY);
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY);
CREATE TABLE session_exercise_timeline(session_id TEXT,entry_id TEXT,
exercise_id TEXT,started_at TEXT,ended_at TEXT,imported_at TEXT);
CREATE TABLE heart_rate_captures(capture_id TEXT PRIMARY KEY,context_kind TEXT,
context_id TEXT,started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
CREATE TABLE heart_rate_samples(capture_id TEXT,sequence INTEGER,observed_at TEXT,
bpm INTEGER,exercise_entry_id TEXT,sensor_contact_detected INTEGER,
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE);
CREATE TABLE heart_rate_rr_intervals(capture_id TEXT,sample_sequence INTEGER,
rr_index INTEGER,value_1024 INTEGER,
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);
CREATE TABLE sync_causal_operations(operation_id TEXT PRIMARY KEY,target_kind TEXT,
target_id TEXT,creator_id TEXT,predecessor_revision_id TEXT,created_at TEXT,
payload_sha256 TEXT,publication_context TEXT);
CREATE TABLE sync_causal_state(target_kind TEXT,target_id TEXT,
current_revision_id TEXT,deleted INTEGER,operation_id TEXT,
PRIMARY KEY(target_kind,target_id));
PRAGMA user_version=36;
"""
)
def seed(db: sqlite3.Connection, ended_at: str = CUTOFF,
include_tail: bool = True) -> dict:
db.execute(
"INSERT INTO sessions VALUES(1,?,?,?,?,?)",
(SESSION, "2026-09-24T10:00:00+02:00", ended_at, "training", "keep"),
)
db.execute("INSERT INTO session_exercises VALUES(1,1)")
db.execute("INSERT INTO performed_sets VALUES(1,1)")
db.execute(
"INSERT INTO session_exercise_timeline VALUES(?,?,?,?,?,?)",
(SESSION, "sxe_33333333-3333-4333-8333-333333333333",
"ex_44444444-4444-4444-8444-444444444444",
"2026-09-24T10:15:00+02:00", "2026-09-24T11:15:00+02:00", CUTOFF),
)
capture_end = "2026-09-24T11:16:00.000001+02:00" if include_tail else CUTOFF
db.execute(
"INSERT INTO heart_rate_captures VALUES(?,?,?,?,?,?,?)",
(CAPTURE, "session", SESSION, "2026-09-24T10:00:00+02:00", capture_end,
"Synthetic", CUTOFF),
)
samples = [
(CAPTURE, 0, "2026-09-24T11:15:59.999999+02:00", 100, None, 1, 1),
(CAPTURE, 1, CUTOFF, 101, None, 1, 2),
]
if include_tail:
samples.append((CAPTURE, 2, "2026-09-24T11:16:00.000001+02:00",
40, None, 0, 3))
db.executemany("INSERT INTO heart_rate_samples VALUES(?,?,?,?,?,?,?)", samples)
db.executemany(
"INSERT INTO heart_rate_rr_intervals VALUES(?,?,0,?)",
[(CAPTURE, row[1], 900 + row[1]) for row in samples],
)
db.execute(
"INSERT INTO heart_rate_captures VALUES(?,?,?,?,?,?,?)",
(OTHER_CAPTURE, "session", OTHER_SESSION, "2026-09-24T09:00:00+02:00",
"2026-09-24T09:01:00+02:00", "Other", CUTOFF),
)
db.execute(
"INSERT INTO heart_rate_samples VALUES(?,?,?,?,?,?,?)",
(OTHER_CAPTURE, 0, "2026-09-24T09:00:30+02:00", 77, None, 1, None),
)
db.commit()
return {
"format": "trainlog-heart-rate",
"version": 1,
"generated_at": CUTOFF,
"captures": [{
"capture_id": CAPTURE,
"context_kind": "session",
"context_id": SESSION,
"started_at": "2026-09-24T10:00:00+02:00",
"ended_at": capture_end,
"sensor_name": "Synthetic",
"samples": [{
"sequence": row[1], "observed_at": row[2], "bpm": row[3],
"exercise_entry_id": None, "sensor_contact_detected": bool(row[5]),
"energy_expended": row[6], "rr_intervals_1024": [900 + row[1]],
} for row in samples],
}],
}
class HeartRateTailRecoveryTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.path = Path(self.temp.name) / "trainlog.sqlite"
self.db = sqlite3.connect(self.path)
create_schema(self.db)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def apply(self, operation=None, fail=False):
return recovery.recover(
self.db, SESSION, CAPTURE, CUTOFF, "peer_test", True,
operation=operation, fail_after_effect=fail,
)
def test_exact_cutoff_rr_stats_and_old_generation_replay(self):
old_generation = seed(self.db)
report, operation = self.apply()
self.assertEqual("applied", report["outcome"])
self.assertEqual(1, report["before"]["bpm_remove"])
self.assertEqual(1, report["before"]["rr_remove"])
self.assertEqual(0, report["after"]["bpm_remove"])
self.assertEqual(100.5, report["after"]["current_stats"]["average"])
self.assertEqual(
[(0, "2026-09-24T11:15:59.999999+02:00"), (1, CUTOFF)],
self.db.execute(
"SELECT sequence,observed_at FROM heart_rate_samples "
"WHERE capture_id=? ORDER BY sequence", (CAPTURE,),
).fetchall(),
)
self.assertEqual(2, self.db.execute(
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?", (CAPTURE,),
).fetchone()[0])
correction_document = heart_rate_correction_exchange.export_document(self.db)
self.assertEqual([operation], correction_document["corrections"])
self.assertEqual([], causal_delete_exchange.export_document(self.db)["operations"])
self.assertEqual((0, 1), heart_rate_exchange.apply(
self.db, heart_rate_exchange.validate(old_generation),
))
second, _ = self.apply(operation)
self.assertEqual("unchanged", second["outcome"])
self.assertEqual(2, second["after"]["bpm_total"])
def test_dry_run_and_injected_failure_roll_back_every_table(self):
seed(self.db)
before = list(self.db.iterdump())
report, _ = recovery.recover(
self.db, SESSION, CAPTURE, CUTOFF, "peer_test", False,
)
self.assertEqual("dry-run", report["mode"])
self.assertEqual(before, list(self.db.iterdump()))
with self.assertRaisesRegex(recovery.RecoveryError, "injected failure"):
self.apply(fail=True)
self.assertEqual(before, list(self.db.iterdump()))
def test_wrong_id_and_invalid_temporal_bounds_are_rejected(self):
seed(self.db)
with self.assertRaisesRegex(recovery.RecoveryError, "session not found"):
recovery.recover(self.db, OTHER_SESSION, CAPTURE, CUTOFF, "peer", False)
with self.assertRaisesRegex(recovery.RecoveryError, "capture"):
recovery.recover(
self.db, SESSION,
"hrc_77777777-7777-4777-8777-777777777777", CUTOFF, "peer", False,
)
self.db.execute(
"UPDATE sessions SET ended_at='2026-09-24T09:59:59+02:00' WHERE session_id=?",
(SESSION,),
)
self.db.commit()
with self.assertRaisesRegex(Exception, "outside capture"):
recovery.recover(
self.db, SESSION, CAPTURE, "2026-09-24T09:59:59+02:00", "peer", False,
)
self.db.execute("UPDATE sessions SET ended_at=? WHERE session_id=?", (CUTOFF, SESSION))
self.db.execute(
"UPDATE session_exercise_timeline SET ended_at='2026-09-24T11:16:01+02:00'",
)
self.db.commit()
with self.assertRaisesRegex(Exception, "precedes the last exercise"):
self.apply()
def test_already_bounded_capture_is_noop(self):
seed(self.db, include_tail=False)
report, operation = self.apply()
self.assertEqual("unchanged", report["outcome"])
self.assertIsNone(operation)
self.assertEqual(0, self.db.execute(
"SELECT COUNT(*) FROM sync_causal_operations",
).fetchone()[0])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
import sqlite3
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import session_timeline_exchange as timeline
SESSION = "se_11111111-1111-4111-8111-111111111111"
ENTRY = "sxe_22222222-2222-4222-8222-222222222222"
EXERCISE = "ex_33333333-3333-4333-8333-333333333333"
def schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
CREATE TABLE sessions(
id INTEGER PRIMARY KEY,
session_id TEXT UNIQUE,
started_at TEXT,
ended_at TEXT
);
CREATE TABLE exercises(
id INTEGER PRIMARY KEY,
exercise_id TEXT UNIQUE
);
CREATE TABLE session_exercises(
id INTEGER PRIMARY KEY,
session_row_id INTEGER,
exercise_row_id INTEGER,
entry_id TEXT UNIQUE
);
CREATE TABLE session_exercise_timeline(
session_id TEXT,
entry_id TEXT,
exercise_id TEXT,
started_at TEXT,
ended_at TEXT,
imported_at TEXT,
PRIMARY KEY(session_id,entry_id)
);
PRAGMA user_version=31;
"""
)
db.execute(
"INSERT INTO sessions(id,session_id,started_at,ended_at) VALUES(1,?,?,?)",
(SESSION, "2026-09-23T08:00:00+02:00", "2026-09-23T09:00:00+02:00"),
)
db.execute("INSERT INTO exercises(id,exercise_id) VALUES(1,?)", (EXERCISE,))
db.execute(
"INSERT INTO session_exercises(id,session_row_id,exercise_row_id,entry_id) "
"VALUES(1,1,1,?)",
(ENTRY,),
)
def document() -> dict:
return {
"format": "trainlog-session-timeline",
"version": 1,
"generated_at": "2026-09-23T09:01:00+02:00",
"sessions": [
{
"session_id": SESSION,
"started_at": "2026-09-23T08:00:00+02:00",
"ended_at": "2026-09-23T09:00:00+02:00",
"exercises": [
{
"entry_id": ENTRY,
"exercise_id": EXERCISE,
"started_at": "2026-09-23T08:10:00+02:00",
"ended_at": "2026-09-23T08:20:00+02:00",
}
],
}
],
}
class SessionTimelineExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = sqlite3.connect(Path(self.temp.name) / "timeline.sqlite")
schema(self.db)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay(self):
payload = timeline.validate(document())
self.assertEqual((1, 0), timeline.apply(self.db, payload))
self.assertEqual((0, 1), timeline.apply(self.db, payload))
self.assertEqual(
(
SESSION,
ENTRY,
EXERCISE,
"2026-09-23T08:10:00+02:00",
"2026-09-23T08:20:00+02:00",
),
self.db.execute(
"SELECT session_id,entry_id,exercise_id,started_at,ended_at "
"FROM session_exercise_timeline"
).fetchone(),
)
def test_rejects_identity_reuse_with_different_content(self):
payload = timeline.validate(document())
timeline.apply(self.db, payload)
changed = deepcopy(document())
changed["sessions"][0]["exercises"][0]["ended_at"] = "2026-09-23T08:21:00+02:00"
with self.assertRaisesRegex(ValueError, "different content"):
timeline.apply(self.db, timeline.validate(changed))
def test_rejects_occurrence_that_does_not_match_history(self):
payload = document()
payload["sessions"][0]["exercises"][0]["exercise_id"] = (
"ex_44444444-4444-4444-8444-444444444444"
)
with self.assertRaisesRegex(ValueError, "does not match session history"):
timeline.apply(self.db, timeline.validate(payload))
def test_rejects_exercise_outside_session_bounds(self):
payload = document()
payload["sessions"][0]["exercises"][0]["started_at"] = "2026-09-23T07:59:59+02:00"
with self.assertRaisesRegex(ValueError, "outside session"):
timeline.validate(payload)
if __name__ == "__main__":
unittest.main()

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