938 lines
40 KiB
Markdown
938 lines
40 KiB
Markdown
# Architecture
|
|
|
|
Full-generation synchronization uses the common admission lock and a fixed
|
|
coordinator worker. Browser input cannot select commands, paths, timeouts or
|
|
capabilities. Peer identity/capabilities come from Android advertisement;
|
|
Android participates only during a foreground user action. Typed progress is
|
|
bounded and durable, and completion is corroborated by SQLite generation/ACK
|
|
records.
|
|
|
|
## 1. System boundary
|
|
|
|
Trainlog Core is the product authority and separates capture, durable history,
|
|
business logic, transport, and presentation. The TUI, Web, and Android are
|
|
sibling adapters with different product responsibilities; none owns a parallel
|
|
business model.
|
|
|
|
```text
|
|
Android field client
|
|
|
|
|
| local SQLite
|
|
|
|
|
+-- automatic mobile snapshot
|
|
|
|
|
v
|
|
Android shared storage
|
|
Documents/Trainlog
|
|
|
|
|
| direct MTP
|
|
v
|
|
shared desktop sync engine
|
|
/ \
|
|
/ \
|
|
desktop SQLite directional PC artifacts
|
|
| |
|
|
v v
|
|
Trainlog Core Android
|
|
|
|
|
+-- desktop TUI
|
|
+-- local Web adapter -> browser
|
|
```
|
|
|
|
The desktop SQLite database is the canonical local source of truth and
|
|
long-term history. Core owns data, persistence, business logic, calculations,
|
|
read models, command services, and versioned contracts. An interface must not
|
|
query tables directly to reconstruct its own business rules.
|
|
|
|
Android local SQLite is a capture store, not a synchronization format.
|
|
|
|
## 2. Components
|
|
|
|
### Android application
|
|
|
|
Responsibilities:
|
|
|
|
- exercise catalog entry;
|
|
- stable-ID exercise rename/editing;
|
|
- canonical body-zone selection, display and descendant filtering;
|
|
- workout-session recording;
|
|
- performed set entry;
|
|
- continuous-activity entry;
|
|
- body measurement entry;
|
|
- local history/detail;
|
|
- automatic mobile snapshot generation;
|
|
- PC catalog application;
|
|
- synchronization request creation;
|
|
- synchronization receipt display.
|
|
- retrieval of a session prepared through Trainlog;
|
|
- future sensor-data acquisition, including BLE heart rate.
|
|
|
|
Android is the field adapter for gym use, actual-work capture, feedback, and
|
|
returning observations to Trainlog. It is not responsible for canonical
|
|
long-term analytics or business truth and never accesses desktop SQLite
|
|
directly.
|
|
|
|
### Desktop core
|
|
|
|
The C17 core owns:
|
|
|
|
- data and domain rules;
|
|
- desktop SQLite persistence;
|
|
- business calculations;
|
|
- typed read models and transactional command services;
|
|
- versioned contracts;
|
|
- exercise/catalog rules;
|
|
- generated body-zone taxonomy and relation APIs;
|
|
- profile-aware session data;
|
|
- body data;
|
|
- ID and time helpers;
|
|
- USB discovery;
|
|
- direct MTP operations;
|
|
- the shared bidirectional synchronization engine.
|
|
|
|
### TUI
|
|
|
|
The TUI is the administration, inspection, maintenance, import/export, and
|
|
technical-tooling adapter. The Notcurses layer owns terminal interaction and
|
|
rendering. It is confined to the desktop executable; persistence,
|
|
synchronization, and core services have no terminal-library dependency.
|
|
|
|
It consumes core services for:
|
|
|
|
- session entry/editing;
|
|
- history;
|
|
- exercise performance;
|
|
- exercise body-zone creation/edit/detail and filters;
|
|
- body tracking;
|
|
- graphs;
|
|
- manual synchronization;
|
|
- synchronization log/detail display.
|
|
|
|
APP_SHELL_V1 places these existing capabilities below seven platform-neutral
|
|
sections: Accueil, Séances, Exercices, Statistiques,
|
|
Synchronisation and Paramètres. The controller owns route history, focus,
|
|
overlays and transient leave guards; rendering owns no persistence, SQLite,
|
|
transport, or synchronization decisions. Navigation and redraw alone never
|
|
write a database or run synchronization. Session drafts, generator previews,
|
|
and transient forms use explicit keep/discard guards; discard of transient TUI
|
|
state performs no database write. Sync operations retain their existing
|
|
direction confirmation and diagnostic ownership.
|
|
|
|
The desktop controller has one event loop and consumes Trainlog input semantics
|
|
before screen code. Overlay input precedes route aliases, local editors precede
|
|
global aliases, and overlay close restores saved focus/stable selection. Its
|
|
layout is compact from 72x20, has a sidebar from 100x26, and expands it from
|
|
120x32. Its bounded UTF-8 adapter, stable-ID list state, shared action registry
|
|
and run-scoped terminal planes keep Notcurses infrastructure separate from
|
|
product semantics.
|
|
|
|
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` resolves the former Dashboard
|
|
layering violation. `tui/src/dashboard.c` owns its frozen TUI-compatible SQLite
|
|
projection; `tui/src/web_dashboard.c` owns the distinct Web aggregate and
|
|
composes that projection only where semantics match.
|
|
normalization, bounds, ordering, rolling windows, aggregates and legacy-data
|
|
flags. Its public `trainlog/dashboard.h` boundary contains no SQLite, Notcurses
|
|
or private TUI type. The TUI calls that service with an explicit reference Unix
|
|
second and presents the caller-owned bounded snapshot; it neither includes
|
|
`database_internal.h` for the Dashboard nor reproduces its calculations.
|
|
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` was completed in the same tranche
|
|
because live TUI adoption is the parity proof. This was an incremental
|
|
extraction, not a global `tui.c` refactor.
|
|
|
|
### Local Web application
|
|
|
|
`TRAINLOG_WEB_V1` is a sibling adapter of the TUI and Android, never an
|
|
extension of the TUI. It is launched by either:
|
|
|
|
```text
|
|
trainlog -w
|
|
trainlog --web
|
|
```
|
|
|
|
Trainlog serves the Web application and its versioned HTTP API to a browser.
|
|
The Web owns analysis, visualization, program preparation, and session
|
|
preparation. Initial API introduction is read-only; later program and session
|
|
mutations must call explicit transactional Core command services. Generic CRUD
|
|
over SQLite tables is forbidden.
|
|
|
|
The V1 network contract is:
|
|
|
|
```text
|
|
address = 127.0.0.1
|
|
default port = 8080
|
|
explicit override = trainlog -w --port <port>
|
|
IPv6 = out of scope
|
|
```
|
|
|
|
A requested occupied port is an explicit startup failure. Trainlog never
|
|
selects another port silently and never binds to a LAN address implicitly.
|
|
|
|
The architectural boundary is:
|
|
|
|
```text
|
|
Browser
|
|
|
|
|
| HTTP / independently versioned /api/v1/
|
|
v
|
|
Web adapter
|
|
|
|
|
| typed Core services
|
|
v
|
|
Trainlog Core
|
|
|
|
|
v
|
|
desktop SQLite
|
|
```
|
|
|
|
The following dependencies are forbidden:
|
|
|
|
```text
|
|
Browser -> SQLite
|
|
React -> SQL
|
|
Web route -> database_internal.h
|
|
Web route -> duplicated TUI calculation
|
|
```
|
|
|
|
The Web API version is independent of the SQLite schema,
|
|
`TRAINLOG_FORMAT_V1`, mobile/synchronization/export formats, and the Trainlog
|
|
product version. Web evolution must not alter a frozen exchange format.
|
|
|
|
V1 preserves serialized Core access: one process-owned `TrainlogDatabase` is
|
|
opened and migrated once before HTTP listening, used without concurrent direct
|
|
SQLite-handle access, and closed by the process owner after server shutdown. A
|
|
single-threaded event loop is the preferred server architecture.
|
|
|
|
`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` implements that owner. CLI parsing
|
|
precedes all path, database, terminal and HTTP initialization. The common owner
|
|
resolves the private XDG database path, opens/migrates it exactly once, dispatches
|
|
to either the unchanged TUI or Web adapter, then closes it exactly once.
|
|
`--help`, `--version` and CLI errors stop before path or database access.
|
|
|
|
The HTTP adapter uses GNU libmicrohttpd in external-select mode with
|
|
`MHD_USE_NO_THREAD_SAFETY`: libmicrohttpd creates no polling or request thread.
|
|
All callbacks, future Core calls and SQLite access therefore run serially on
|
|
the process event-loop thread. A concrete IPv4 `sockaddr_in` binds only
|
|
`127.0.0.1`; port zero remains unavailable through the CLI. SIGINT/SIGTERM
|
|
handlers only set a `sig_atomic_t` flag. The loop then stops libmicrohttpd,
|
|
returns to the process owner and lets it close the database.
|
|
|
|
The read-only technical route remains `GET /api/v1/health`; unknown API routes
|
|
return JSON 404 and never fall through to the SPA. The adapter
|
|
bounds concurrent/per-IP connections at 32, listen backlog at 32, per-connection
|
|
memory at 16 KiB, decoded request headers at 8 KiB, request bodies at 4 KiB and
|
|
idle time at 10 seconds. It validates `Host` against `127.0.0.1` and the active
|
|
port, emits explicit JSON content type, `nosniff`, `no-store`, a deny-all CSP
|
|
and no permissive CORS header. Assets, telemetry and remote resources are
|
|
absent. Before the first mutation route, origin validation and explicit
|
|
CSRF protection remain mandatory in addition to these transport bounds.
|
|
|
|
`WEB_FRONTEND_SHELL_V1=PASS/FROZEN` adds the sibling `web/` React 19,
|
|
TypeScript and Vite application. A History API router owned by Trainlog handles
|
|
the five fixed shell destinations without another runtime dependency. `/`,
|
|
`/analyse`, `/programmes`, `/seances`, and `/exercices` receive the embedded
|
|
`index.html`; exact generated asset URLs receive only their manifest bytes.
|
|
Unknown assets and traversal attempts are 404. `HEAD` is supported for UI
|
|
assets; other asset methods are 405. API dispatch remains separate.
|
|
|
|
Vite writes only into the Meson build directory. A bounded deterministic
|
|
Python generator sorts at most 128 known files, rejects files over 4 MiB or a
|
|
manifest over 16 MiB, assigns MIME and SHA-256 ETag metadata, then emits static
|
|
C arrays linked into `trainlog`. Runtime serving performs exact URL lookup and
|
|
never reads a filesystem path. HTML uses `no-cache`; hash-named Vite assets use
|
|
one-year immutable caching. The CSP permits only same-origin scripts, styles,
|
|
connections and images, with no `unsafe-inline` or `unsafe-eval`.
|
|
|
|
The Meson feature option `web=auto|enabled|disabled` separates provisioning
|
|
from compilation. Meson never installs npm packages. `auto` embeds assets only
|
|
when Node, npm and prepared `web/node_modules` exist; `enabled` makes those
|
|
build prerequisites mandatory and is required for releases; `disabled`
|
|
produces a desktop-only binary whose Web launch fails explicitly. An embedded
|
|
binary requires neither Node/npm nor `web/dist` at runtime.
|
|
|
|
Every Web page retains one application shell:
|
|
|
|
- Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices;
|
|
- Body: responsive tile-organized page content;
|
|
- Footer: user, date of the latest completed session, and all primary BODY
|
|
ZONES worked in that completed session.
|
|
|
|
The Unix login is the V1 user fallback until Trainlog owns an explicit display
|
|
name. Footer zones come only from the latest completed session; missing facts
|
|
remain unavailable rather than being invented. Header and Footer remain
|
|
present during navigation.
|
|
|
|
The sole V1 theme is Catppuccin Mocha with Lavender as its primary accent.
|
|
There is no V1 light theme. The browser is the client and Electron is outside
|
|
the architecture. React, TypeScript, Vite, and Apache ECharts are the intended
|
|
frontend stack. Node and npm are build-only dependencies; installed Trainlog
|
|
must not require either. All production assets are local, built ahead of time,
|
|
and incorporated into the `trainlog` binary from the sibling root `web/`
|
|
directory. The shell implements semantic Header/Body/Footer landmarks,
|
|
keyboard-visible focus, responsive tile scaffolding and reduced-motion rules.
|
|
Absent user/session/zone facts remain visibly unavailable. The bounded grid
|
|
engine uses `react-grid-layout` 2.2.4 behind a Trainlog-owned model, adapter and
|
|
validator. The implemented Progression visualization loads modular ECharts
|
|
6.1.0 lazily; the BODY ZONES visualization is an original code-native SVG.
|
|
|
|
The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
|
|
|
|
1. Prochaine séance;
|
|
2. Activité;
|
|
3. Progression;
|
|
4. Dernière séance;
|
|
5. Records / MAX;
|
|
6. Répartition musculaire;
|
|
7. Cardio / récupération.
|
|
|
|
`WEB_DASHBOARD_GRID_V1=PASS/FROZEN` supports drag and drop, horizontal and
|
|
vertical resizing, tile-specific minimum/maximum sizes, automatic
|
|
reorganization, responsive projection, versioned private layout persistence,
|
|
and restoration of the Trainlog default. Dragging and resizing are enabled only by
|
|
the explicit `Modifier l'agencement` mode. A larger tile may reveal more
|
|
information, but tile size never changes a metric's business definition.
|
|
|
|
The single persisted source layout is the canonical desktop 12-column layout:
|
|
|
|
```text
|
|
$XDG_CONFIG_HOME/trainlog/web/dashboard-layout-v1.json
|
|
```
|
|
|
|
with fallback:
|
|
|
|
```text
|
|
$HOME/.config/trainlog/web/dashboard-layout-v1.json
|
|
```
|
|
|
|
Its separately versioned format is interface configuration, not training data.
|
|
It does not enter business SQLite V1, `TRAINLOG_FORMAT_V1`, scientific or AI
|
|
exports, or session synchronization. Small-screen layouts are derived
|
|
responsive projections, not a second persisted truth.
|
|
|
|
`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` implements this path as a strict private
|
|
0600 JSON preference, atomically replaced without SQLite involvement. GET/PUT/
|
|
DELETE `/api/v1/dashboard-layout` expose the canonical Trainlog shape. ETag and
|
|
If-Match guard optimistic revisions; mutation additionally requires exact
|
|
loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header.
|
|
Invalid files are preserved for diagnosis while GET safely returns the default.
|
|
|
|
`WEB_DASHBOARD_TILES_V1=PASS/FROZEN` keeps the HTTP boundary singular: `App`
|
|
loads and strictly validates one Dashboard snapshot, then supplies that same
|
|
immutable business view to the Footer and the grid. Tile components perform
|
|
presentation-only selection by logical size and direct sums of the 90 daily
|
|
counts. They never reconstruct activity, progression, session, MAX or zone
|
|
rules. The activity map encodes active/session facts directly; muscle bar
|
|
length is exactly `session_count`, explicitly labelled. Responsive projection
|
|
changes density, not the canonical layout or metric semantics.
|
|
|
|
`WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` keeps visualization behind the
|
|
same typed snapshot. Progression dynamically imports modular ECharts
|
|
(`LineChart`, `GridComponent`, `TooltipComponent`, `SVGRenderer`) only in
|
|
medium/large sizes. One instance is owned per mounted chart, updated from the
|
|
exact comparable series, resized by `ResizeObserver`, and disposed on unmount.
|
|
SVG was selected over Canvas because the bounded series has very few elements
|
|
and vector sharpness/responsive scaling matter more than high-volume drawing.
|
|
All colours are read from the live Catppuccin CSS variables.
|
|
|
|
The muscle figure is an original code-native SVG. Canonical localized mappings
|
|
are: `chest` to bilateral front chest; `back` to rear upper back and bilateral
|
|
lats; `shoulders` to bilateral front/rear shoulders; `arms` to bilateral
|
|
front/rear arms; `core` to the front trunk; `glutes` to bilateral rear glutes;
|
|
`thighs` and `calves` to bilateral front/rear legs. Aggregate zones
|
|
`full_body`, `upper_body`, and `lower_body` deliberately have no SVG region and
|
|
remain in the complete text list. Visual levels are a deterministic ratio to
|
|
the maximum `session_count` in the current snapshot: zero, up to one third, up
|
|
to two thirds, and above two thirds. They communicate comparison only, never
|
|
fatigue, recovery, balance, volume, or physiological quality.
|
|
|
|
`WEB_DASHBOARD_V1=PASS/FROZEN` closes the Dashboard after final review. The
|
|
Progression value axis uses explicit deterministic bounds: constant series get
|
|
a symmetric margin of at least 1 kg (rounded outward to 0.5 kg), while varying
|
|
series get at least 0.5 kg or 10% of their observed span. The lower bound is
|
|
never below zero, and every real observation—including legacy zero—is retained.
|
|
Large Progression tiles place facts and chart side by side so the complete
|
|
domain stays visible without an internal scroll. Dashboard layout persistence,
|
|
responsive projections and the seven domain semantics remain unchanged.
|
|
|
|
`WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only
|
|
`GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every
|
|
projection; statements are finalized before return. The seven domains mean:
|
|
|
|
1. **Prochaine séance** is available only for a persisted plan explicitly
|
|
accepted for execution. Schema v18 has proposals, targets in occurrences and
|
|
completed/observable history, but no such lifecycle gate. V1 therefore
|
|
returns `available=false`, reason `no_persisted_executable_plan`; proposals,
|
|
templates and generated suggestions never qualify.
|
|
2. **Activité** is the 90 local calendar days ending on the backend's current
|
|
local date. A day is active when at least one observable session starts on
|
|
that persisted timestamp's local date. Observable means at least one
|
|
performed set, continuous activity or explicit MAX; `ended_at` is not a
|
|
gate. Multiple sessions are counted separately and sets are counted without
|
|
converting continuous/MAX facts into fake sets.
|
|
3. **Progression** reuses the frozen Core Dashboard selection and exact
|
|
`(exercise_id,equipment_id,tracking_mode,load_mode,dose)` identity over 90
|
|
days. Points and deterministic improvement events never mix identities.
|
|
Frozen legacy present-at-0.0-kg behavior remains unchanged.
|
|
4. **Dernière séance** is the newest observable session by represented instant,
|
|
then stable session ID. Duration exists only with a valid non-earlier
|
|
`ended_at`. Counts distinguish worked occurrences, persisted sets,
|
|
continuous occurrences and explicit MAX. Primary zones come only from
|
|
occurrences that own one of those facts, deduplicated and ordered by
|
|
`zone_id`; catalogue-only exercises never contribute.
|
|
5. **Records / MAX** contains at most eight `max_results`, newest first with
|
|
session and occurrence IDs as deterministic ties. It exposes the source
|
|
exercise/equipment and measured weight; ordinary sets and estimated 1RM
|
|
never qualify.
|
|
6. **Répartition musculaire** covers the 30 local calendar days ending today.
|
|
Each persisted primary zone reports distinct observable sessions, worked
|
|
occurrences and actual sets. Continuous and MAX occurrences count as work
|
|
occurrences/sessions but add no set. Secondary zones and recovery scores
|
|
are excluded.
|
|
7. **Cardio / récupération** is unavailable with stable reason
|
|
`no_cardio_data_source`; duration, load, feedback and continuous activity
|
|
are not cardio proxies.
|
|
|
|
The same snapshot supplies the Unix account name (bounded `getpwuid_r`
|
|
fallback), latest observable session timestamp and that session's worked
|
|
primary-zone labels to the Footer. Absence is represented by `null`, empty
|
|
collections or stable unavailable reasons, never invented zero-valued facts.
|
|
The JSON writer is internal, allocation-free and bounded at 128 KiB; it owns
|
|
escaping for quotes, backslashes, controls and UTF-8. Collections are bounded
|
|
to 90 activity days, eight MAX entries, 17 BODY ZONES, 128 progression points
|
|
and a 4,096-session corruption scan. `meta.partial` reports truncation and
|
|
`meta.invalid_data` reports rejected persisted values independently.
|
|
|
|
V1 security invariants are: loopback-only binding, Host validation, no
|
|
permissive CORS, foreign-origin/CSRF protection for mutations, explicit bounds
|
|
for headers, bodies, connections and timeouts, local assets only, no CDN, no
|
|
implicit telemetry, a Content Security Policy, and orderly `SIGINT`/`SIGTERM`
|
|
shutdown.
|
|
|
|
Android's `AppNavigationController` is the corresponding route owner. Its
|
|
Material 3 drawer exposes the same seven roots and derives drawer selection
|
|
from the route. It uses local vector resources, a sans-serif hierarchy and
|
|
minimum 48 dp actions; it has no runtime icon/parser dependency and accepts a
|
|
text fallback where an optional icon font is unavailable. Route transitions
|
|
preserve durable drafts and require keep/discard resolution for non-durable
|
|
forms or generator previews. The Android shell maps the same semantic color
|
|
roles to Material 3 tokens; neither platform treats color as the only state
|
|
carrier.
|
|
|
|
### `trainlog-syncd`
|
|
|
|
`trainlog-syncd` is a small user-session agent.
|
|
|
|
### Full-generation application orchestrator
|
|
|
|
The opt-in full-generation path is owned by `sync_orchestrator.py`, launched as
|
|
an owned process by the C Web adapter. It uses the same XDG `sync.lock` as the
|
|
legacy engine, never borrows the Web process's SQLite handle, and accepts peer
|
|
transport only from a trusted local configuration. The HTTP event loop only
|
|
admits work and reads a bounded durable report. V3 remains the default when the
|
|
trusted generation configuration is absent.
|
|
|
|
In `mtp` mode the worker invokes one fixed packaged helper. Its production
|
|
callbacks are the existing libudev/libmtp object operations; tests replace only
|
|
that typed I/O table. Candidate selection requires one exact stable peer
|
|
advertisement and required capabilities. Downloads are bounded and size
|
|
checked. Upload retries accept byte-identical objects, and each generation
|
|
manifest is published after its member objects. The helper runs inside the
|
|
orchestrator-owned process group and cannot be selected or parameterized by an
|
|
HTTP request.
|
|
|
|
It polls for a new Android request and invokes the same shared C synchronization
|
|
engine used by the TUI.
|
|
|
|
It does not implement a second synchronization algorithm.
|
|
|
|
## 3. Exercise model
|
|
|
|
Trainlog is metadata-driven:
|
|
|
|
```text
|
|
recording_mode = SETS | CONTINUOUS
|
|
tracking_mode = REPS | DURATION
|
|
data_fields = SPEED_KMH | DISTANCE_KM
|
|
```
|
|
|
|
Valid model-v1 combinations:
|
|
|
|
```text
|
|
SETS + REPS
|
|
SETS + DURATION
|
|
CONTINUOUS + DURATION
|
|
```
|
|
|
|
Load mode is session-specific:
|
|
|
|
```text
|
|
none
|
|
external
|
|
assistance
|
|
```
|
|
|
|
Continuous work is persisted separately from performed sets.
|
|
|
|
Body-zone semantics are a second independent metadata axis:
|
|
|
|
```text
|
|
exercise -> exercise_body_zones -> canonical body-zone manifest
|
|
role = primary | secondary
|
|
```
|
|
|
|
The repository-level `catalog/body-zones-v1.json` is the sole taxonomy source.
|
|
Android reads it as an asset and the desktop generates a bounded C
|
|
representation at build time. Stable `zone_id` values cross persistence and
|
|
synchronization boundaries; translated display names do not. `upper_body` and
|
|
`lower_body` are hierarchy groups whose descendant membership is derived at
|
|
query time, never persisted redundantly. `full_body` and `core` are autonomous.
|
|
An unclassified exercise has no relation at all: a secondary-only state is
|
|
invalid at repository, database and exchange boundaries rather than being
|
|
silently rendered as unclassified.
|
|
|
|
## 4. Persistence ownership
|
|
|
|
### Desktop
|
|
|
|
Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
|
|
v26 migration adds terminal Program logical deletion and its durable replay,
|
|
generation, and acknowledgement ledger without deleting imported definitions,
|
|
derived preparations, or workout facts. Android schema v24 maintains the
|
|
separate read-only synchronized Programs projection; the optional staged
|
|
`trainlog-programs` V1 companion does not replace mobile V3 or change
|
|
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
|
|
manual-preparation withdrawal identity without deleting revisions, deliveries
|
|
or workout facts. Its additive v18 ->
|
|
v19 migration stores causal notes and execution-draft lifecycle state without
|
|
activating a new transport. Its v15 -> v16
|
|
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the
|
|
one-time backfill copies the linked catalogue mode without changing child rows
|
|
or stable identities. The additive v16 -> v17 migration adds deterministic
|
|
current-profile ancestry in a 32-record-per-exercise durable chain without
|
|
altering those occurrence snapshots. The v14
|
|
-> v15 additive step introduces immutable wording-revision tables for the subjective
|
|
feedback roots introduced in v14; see [Training feedback](training_feedback.md). Its v9 -> v10
|
|
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
|
be finite `>= 0`; the column already existed and targets/max results retain
|
|
their strictly-positive contracts. `session_exercises`
|
|
stores a stable occurrence `entry_id`; a catalogue `exercise_id` can therefore
|
|
occur more than once in one session without identity fusion.
|
|
|
|
The additive v17 -> v18 migration adds a desktop-private AI proposal inbox:
|
|
imported headers, ordered target-only entries, an immutable `(aid_ draft_id,
|
|
payload digest)` import identity, and a nullable successful-publication cursor.
|
|
The cursor drains deterministic batches without deleting replay identity. It is
|
|
intentionally not the desktop TUI's
|
|
in-progress session state and it never creates actual sets. The separate
|
|
Android v17 migration adds a pending AI-proposal collection and permanent
|
|
`started`/`deleted` tombstones; the pre-existing
|
|
`active_session_draft(id=1)` remains the one capture draft. An explicit start
|
|
transaction copies a pending proposal's targets into that singleton and
|
|
tombstones the proposal; a delete transaction tombstones it. This keeps replay
|
|
idempotent without silently resurrecting user-dismissed work.
|
|
|
|
The additive v10 -> v11 migration creates direct exercise/body-zone relations
|
|
and a private synchronization baseline, then seeds only stable-ID mappings
|
|
whose decision evidence is recorded in the manifest. It never rewrites an
|
|
exercise, occurrence or history row.
|
|
|
|
The additive v11 -> v12 migration adds only persistent exercise aliases. An
|
|
explicit source-to-canonical merge validates the complete recording profile and
|
|
primary BODY ZONE, unions compatible secondary zones, repoints occurrence
|
|
foreign keys, and then retires the source catalogue row. Entry IDs, sessions,
|
|
sets, continuous activity, MAX, equipment and targets remain unchanged.
|
|
|
|
Main tables:
|
|
|
|
```text
|
|
exercises
|
|
sessions
|
|
session_exercises
|
|
performed_sets
|
|
continuous_activity
|
|
max_results
|
|
body_observations
|
|
custom_equipment
|
|
exercise_body_zones
|
|
exercise_body_zone_sync
|
|
```
|
|
|
|
### Android
|
|
|
|
Android has an independent local SQLite schema, currently v14. Completed and
|
|
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
|
|
resuming a completed Test max records its stable source session in the one
|
|
durable draft.
|
|
|
|
Its v9 -> v10 migration adds equivalent exercise/body-zone relations and seeds
|
|
the same manifest mappings. User creation and editing replace name/profile and
|
|
zone relations in one repository transaction.
|
|
|
|
It mirrors domain concepts needed for capture, but its schema version is not
|
|
coupled to the desktop schema.
|
|
|
|
Synchronization exchanges domain artifacts rather than database files.
|
|
|
|
`TrainlogRepository` owns a singleton active-session draft, its ordered exercise
|
|
and actual-value children, and raw form text. Compose sends meaningful mutations
|
|
to that repository; lifecycle callbacks are not the sole persistence boundary.
|
|
Normal navigation never deletes the draft. Home restores the resume affordance
|
|
from SQLite after process recreation.
|
|
|
|
Drafts use separate tables from completed sessions and are never export sources.
|
|
Finalization inserts the completed session and deletes the draft in one
|
|
transaction; failures retain the draft. Catalog row references preserve draft
|
|
identity through existing PC-catalog reconciliation. Missing editing-selection
|
|
recovery preserves the raw fields and added exercises with a specific warning.
|
|
|
|
`TrainlogRepository.editExercise()` owns all Android exercise edits. It changes
|
|
the display name and normalized form in the existing catalog row identified by
|
|
`exercise_id`; foreign-key ownership consequently preserves completed history
|
|
and active drafts. A profile edit is admitted only before that row is referenced
|
|
by either completed or active-draft data. Same-ID reconciliation applies name
|
|
metadata in place. A different-ID normalized-name collision may merge only for
|
|
equal recording/tracking modes, compatible represented invariants, and
|
|
`data_fields` masks comparable by inclusion. Desktop ownership wins on both
|
|
sides; the richer mask is retained and every occurrence/draft reference moves
|
|
transactionally. Otherwise synchronization reports a conflict.
|
|
|
|
Compose presentation has one fixed `AndroidAppShell` Material 3 `Scaffold`
|
|
with a `TopAppBar` above its page-content host. Root routes show `TRAINLOG`;
|
|
non-root routes show their route title. Screen navigation and data ownership
|
|
remain independent from this shell.
|
|
|
|
## 5. Compatibility boundaries
|
|
|
|
### Session generator V1
|
|
|
|
`SESSION_GENERATOR_V1=PASS`. `catalog/session-generation-policy-v1.json` is its sole authored
|
|
policy source; generated C data and Android asset loading consume that same
|
|
policy. The generator composes read-only runtime/knowledge context, complete
|
|
history, deterministic selection and explicit uncertainty into an in-memory
|
|
proposal. Preview writes nothing. Android acceptance creates the normal
|
|
singleton draft atomically; TUI acceptance enters the normal editor. There is
|
|
no generated-session persistence silo.
|
|
|
|
Exposure counts actual positive-repetition completed SETS+REPS rows only.
|
|
The exclusive thresholds are primary/secondary 1/3 at 24 hours and 6/12 at 72
|
|
hours; primary produces `warning`, secondary-only produces `notice`, and `none`
|
|
is available only after successful analysis. Invalid stored time fails analysis;
|
|
these signals do not estimate recovery. Selection is bounded to six exercises,
|
|
uses requested-zone/group coverage, diversity, compatible equipment, exclusions,
|
|
preferences, recency and deterministic ID ties, and returns shortages rather
|
|
than invented candidates. Numeric weight is only an unchanged minimum observed
|
|
qualifying actual external-load dose for the exact exercise/equipment within
|
|
28 days; MAX, assistance and unknown context do not prescribe a number.
|
|
|
|
### Frozen Trainlog JSON v1
|
|
|
|
`TRAINLOG_FORMAT_V1` is frozen and remains a compatibility boundary for its
|
|
existing set-based session contract.
|
|
|
|
### Synchronization artifacts
|
|
|
|
Synchronization uses separate formats:
|
|
|
|
```text
|
|
trainlog-mobile-export v1
|
|
trainlog-mobile-export v2
|
|
trainlog-mobile-export v3
|
|
trainlog-pc-catalog v1
|
|
trainlog-equipment-associations v2
|
|
trainlog-equipment-definitions v1
|
|
trainlog-exercise-body-zones v1
|
|
trainlog-sync-request v1
|
|
trainlog-sync-receipt v1
|
|
```
|
|
|
|
A new domain requirement must not be forced into frozen v1 by using notes,
|
|
synthetic sets, or data loss.
|
|
|
|
The active occurrence-aware session exchange is
|
|
`trainlog-mobile-export` v3. V2 remains a readable historical artifact. The separate directional
|
|
`trainlog-equipment-definitions` v1 artifacts carry user-created equipment
|
|
definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from
|
|
Android to PC and `trainlog-pc-equipment-definitions-v1.json` travels from PC
|
|
to Android. The filename identifies direction; the JSON format and version do
|
|
not change. V1 historical artifacts remain readable under their frozen
|
|
contracts.
|
|
|
|
`trainlog-exercise-body-zones-v1.json` is the one direction-neutral zone
|
|
companion in both directions. It carries only `exercise_id`, a nullable
|
|
`primary_zone_id` and `secondary_zone_ids`; taxonomy definitions stay in the
|
|
canonical manifest.
|
|
|
|
## 6. Direct MTP transport
|
|
|
|
Linux transport:
|
|
|
|
```text
|
|
physical Android USB device
|
|
|
|
|
v
|
|
libudev discovery
|
|
|
|
|
| bus + device number
|
|
v
|
|
libmtp exact raw-device access
|
|
|
|
|
v
|
|
Android internal storage
|
|
```
|
|
|
|
No GVFS/FUSE mount is required.
|
|
|
|
Canonical exchange directory:
|
|
|
|
```text
|
|
Documents/Trainlog
|
|
```
|
|
|
|
## 7. Shared synchronization engine
|
|
|
|
Both user-trigger paths call:
|
|
|
|
```text
|
|
trainlog_sync_run()
|
|
```
|
|
|
|
Manual path:
|
|
|
|
```text
|
|
TUI -> trainlog_sync_run()
|
|
```
|
|
|
|
Android-triggered path:
|
|
|
|
```text
|
|
Android request
|
|
-> trainlog-syncd
|
|
-> trainlog_sync_run()
|
|
-> receipt
|
|
```
|
|
|
|
The same engine owns all supported directions. Its explicit modes are:
|
|
|
|
```text
|
|
a = Android -> PC only
|
|
p = PC -> Android only
|
|
b = Android -> PC, then PC -> Android
|
|
```
|
|
|
|
The Android-to-PC direction receives the mobile equipment-definitions v1
|
|
artifact, mobile-export v3, and equipment-associations v2. The PC-to-Android
|
|
direction publishes PC equipment-definitions v1 before dependent artifacts,
|
|
then publishes the PC catalog v1, PC mobile-export v3 (including completed
|
|
sessions and body observations), and equipment-associations v2.
|
|
Both directions also transfer the same body-zone companion after exercise
|
|
definitions are established and before completion of the direction.
|
|
|
|
The engine performs the applicable direction steps:
|
|
|
|
```text
|
|
1. direct-MTP device/storage discovery
|
|
2. exchange-folder resolution
|
|
3. definition artifact transfer and reconciliation before dependent V2 data
|
|
4. strict transactional Android -> PC import and body-zone reconciliation when selected
|
|
5. PC catalog, body-zone, mobile/body, and association export when selected
|
|
6. direct-MTP publication of the selected PC -> Android artifacts
|
|
7. optional request receipt publication
|
|
8. structured run-history recording
|
|
```
|
|
|
|
Synchronization is additive and reconciliatory: artifact absence never implies
|
|
deletion of local content. Equal same-ID definitions are idempotent; divergent
|
|
same-ID definitions, unknown references, and association conflicts are reported
|
|
explicitly. The affected persisted content is preserved on conflict; the engine
|
|
does not silently overwrite it.
|
|
|
|
There is no exercise, session, body-observation, body-zone-relation, or equipment-definition
|
|
tombstone in the current formats. Omitting one of those objects from a later
|
|
snapshot is not a deletion request. The only explicit removal signal is
|
|
equipment-association V2 `state: cleared`, scoped to one
|
|
`(session_id, entry_id)` occurrence.
|
|
|
|
## 8. Synchronization concurrency
|
|
|
|
The shared engine serializes synchronization with:
|
|
|
|
```text
|
|
$XDG_DATA_HOME/trainlog/sync.lock
|
|
```
|
|
|
|
The TUI waits for an active transaction.
|
|
|
|
Daemon polling uses non-blocking acquisition and retries later.
|
|
|
|
A request ID already successfully consumed is not processed as a new request.
|
|
|
|
## 9. Synchronization history
|
|
|
|
Every actual run gets a stable:
|
|
|
|
```text
|
|
sy_<uuid-v4>
|
|
```
|
|
|
|
Structured history is stored under:
|
|
|
|
```text
|
|
$XDG_DATA_HOME/trainlog/sync_runs/
|
|
```
|
|
|
|
The TUI exposes list/detail semantics comparable to:
|
|
|
|
```text
|
|
git log
|
|
git show
|
|
```
|
|
|
|
## 10. Error philosophy
|
|
|
|
Trainlog prefers explicit failure over silent corruption.
|
|
|
|
Hard validation or persistence failure aborts the relevant transaction.
|
|
|
|
The synchronization layer records useful failure detail rather than masking
|
|
known errors with generic placeholders.
|
|
|
|
## 11. Layering rule
|
|
|
|
```text
|
|
Notcurses / Web / Compose adapters
|
|
|
|
|
v
|
|
typed Core application services
|
|
|
|
|
+-- domain model and calculations
|
|
+-- read models and command services
|
|
+-- synchronization and validation
|
|
|
|
|
v
|
|
persistence / MTP transport
|
|
```
|
|
|
|
Rendering does not own persistence rules, SQL, or duplicate business
|
|
calculations.
|
|
|
|
Persistence and MTP code do not depend on Notcurses rendering.
|
|
|
|
## 12. Body analytics boundary
|
|
|
|
Body analytics belong to the desktop analysis layer.
|
|
|
|
```text
|
|
Android
|
|
real measurements only
|
|
|
|
|
v
|
|
desktop body_observations
|
|
|
|
|
v
|
|
pure derived analytics
|
|
|
|
|
v
|
|
TUI display
|
|
```
|
|
|
|
The analytics layer does not modify canonical observations.
|
|
|
|
A desktop-only configuration file stores the estimation profile:
|
|
|
|
```text
|
|
$XDG_CONFIG_HOME/trainlog/body_analytics.conf
|
|
```
|
|
|
|
with `~/.config` fallback.
|
|
|
|
This profile is not synchronized to Android and does not require a SQLite
|
|
schema change.
|
|
|
|
## 13. Audited evolution constraints
|
|
|
|
Each standalone mutating importer has strict validation and a SQLite
|
|
transaction. The V2
|
|
association companion is a strict corroboration of the equipment already
|
|
carried by the mobile snapshot and does not rewrite divergent state. However,
|
|
one definitions/mobile/body-zones/associations publication has no common generation
|
|
manifest in the active V3 path and is not one cross-artifact database
|
|
transaction. A late association
|
|
conflict can therefore follow a successfully committed definitions or mobile
|
|
import; replay remains idempotent and existing conflicting content is not
|
|
overwritten. The explicit staged generation service now supplies that
|
|
separately versioned batch boundary: one source snapshot, immutable artifacts,
|
|
manifest-last publication, complete validation, and one destination
|
|
transaction including consumption/ACK state. It is intentionally not selected
|
|
by the active V3 engine.
|
|
|
|
Body-zone concurrency uses an internal canonical-state baseline. Equal states
|
|
are idempotent; the sole changed side wins; if both local and incoming states
|
|
diverge from the baseline, the companion reports a conflict and rolls back.
|
|
Secondary lists are never merged by union. A safe exercise-identity merge may
|
|
carry the only non-empty zone state, but rejects two different non-empty states
|
|
and clears the baseline because identity reconciliation is not acknowledgement.
|
|
After a companion has actually been published, the publisher records that
|
|
exact snapshot as its own baseline too; a failed file/MTP publication never
|
|
acknowledges data that the peer could not have observed.
|
|
|
|
Android and desktop also have different stored name-normalization behavior:
|
|
Android removes diacritics, while the frozen desktop/Python rule preserves them
|
|
through NFC plus Unicode case folding. Correcting existing Android keys requires
|
|
an explicit migration and collision policy. Finally, Android retains supplied
|
|
exercise/equipment relationship tables, but its current picker searches the
|
|
complete definition catalog; applying those relations as recommendations is
|
|
future gym-catalog behavior.
|
|
|
|
Custom-equipment reconciliation is deliberately ID-based. Different custom
|
|
IDs are not merged by equal or similar display names because their load
|
|
semantics and existing occurrence references may differ. The current PC
|
|
catalog V1 Android reader also validates its required semantic fields without
|
|
the exact-key rejection used by newer V2/companion readers; stricter V1 parsing
|
|
needs an explicit compatibility review.
|
|
|
|
The desktop model/API currently permits bounded supplemental field masks on a
|
|
`SETS` profile, while Android creation and V2 inbox validation require those
|
|
masks to be zero for `SETS`. The shipped catalog uses supplemental speed and
|
|
distance only with `CONTINUOUS`; defining cross-platform behavior for a future
|
|
set-based supplemental field is a model-contract decision, not part of this
|
|
reconciliation.
|
|
|
|
## 14. Training knowledge V1 boundary
|
|
|
|
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. Versioned JSON
|
|
catalogs under `catalog/` are the only authored scientific source; generated C
|
|
data and Android asset loading derive from them. They contain cited anatomy,
|
|
movement, exercise and equipment knowledge, not user history. The generated
|
|
knowledge audit is evidence output, not an editable source.
|
|
|
|
The desktop `training_knowledge.h` API exposes immutable catalog records and
|
|
stable-ID queries. `EQUIPMENT_KNOWLEDGE_V2` adds a separate immutable relation artifact and bounded
|
|
equipment/exercise/zone queries. Zone queries are strictly zone → resolved
|
|
exercise → equipment relation; equipment labels never infer anatomy.
|
|
`training_context.h` combines one exact persisted exercise
|
|
with its stored BODY ZONE relations, optional science, compatible equipment,
|
|
latest explicit maximum, and bounded occurrence/set history under one read
|
|
snapshot. Android provides the corresponding catalog and repository context.
|
|
This composition neither writes SQLite nor seeds catalog mappings. An unknown
|
|
runtime ID and a missing scientific record remain valid states.
|
|
|
|
Scientific BODY ZONE projections and persisted BODY ZONE relations have
|
|
different ownership and are never substituted for one another. Labels and
|
|
generic equipment descriptions are not runtime identities; an equipment
|
|
capability does not create an `ex_<uuid-v4>` exercise or historical association.
|
|
The feature is `TRAINING_KNOWLEDGE_V1=PASS`. The temporal contract has
|
|
independent PASS evidence:
|
|
the readers parse the admitted source forms into exact instants, compare exact
|
|
fractions, then use bytewise session and entry ID ties; emitted exclusive
|
|
cursors preserve the original timestamp text and IDs. Selection and hydration
|
|
share a read snapshot, while separate page calls retain current-data semantics.
|
|
Malformed caller cursors and malformed matching stored timestamps fail
|
|
explicitly; a selected timestamp beyond C's 40-character output field also
|
|
fails explicitly. The initial full-tranche audit's stale temporal-documentation,
|
|
Android loader, Meson input, and C role-only query findings were resolved by one
|
|
bounded repair chain and independently verified. Its full contract, uncertainty boundary and
|
|
future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md).
|
|
|
|
Canonical mapped exercise names are a separate, small versioned presentation
|
|
catalog. Stable `exercise_id` owns both display and normalized metadata for
|
|
mapped IDs during PC/Android reconciliation; it does not alter observations,
|
|
aliases, BODY ZONES, or equipment context.
|