Define Trainlog v1 freeze candidate

This commit is contained in:
fy59 2026-09-05 19:22:37 +02:00
parent ebd4316ed6
commit 9d9a9223a0
32 changed files with 2135 additions and 471 deletions

View file

@ -8,24 +8,24 @@ The project uses a simple pre-release changelog during early development.
### Added ### Added
- Initial repository structure. - Initial repository structure and development contract.
- Development contract. - Architecture, coding-style, database, Android, TUI, testing, and roadmap documentation.
- Architecture documentation. - Trainlog v1 JSON Schema draft.
- Coding-style rules. - Structural and semantic Trainlog validator.
- Exchange-format v1 draft.
- JSON Schema draft for Trainlog v1.
- Initial example workout export.
- Database, TUI, Android, testing, and roadmap documentation.
- Trainlog semantic JSON validator.
- Positive and negative exchange-format fixtures. - Positive and negative exchange-format fixtures.
- Gate 0 review #1 report. - Gate 0 review and closure records.
- Gate 1 exchange-format freeze-candidate review.
- Stable exercise tracking modes.
- Explicit load modes for no load, external resistance, and assistance.
- Optional bounded session and exercise notes.
- Extended body measurement list.
### Changed ### Changed
- Gate 0 project contract review completed successfully. - Gate 0 project contract is complete.
- `ended_at` is optional for active or interrupted sessions. - Gate 1 is the active format-freeze gate.
- Repetition and timed exercise modes are now mutually exclusive. - Actual repetition count may be zero for a failed attempt.
- Exercise display-name anti-duplication semantics are defined. - Planned exercises may contain zero actual sets.
- Session/catalog cross-reference rules are executable. - Top-level exercise metadata must exactly match the session exercise references.
- Timestamp offset and chronology rules are executable. - Assistance kilograms have distinct semantics from external resistance.
- Unknown fields are explicitly rejected in v1. - `TRAINLOG_FORMAT_V1` remains draft pending Gate 1 validation and mirrored review.

View file

@ -6,9 +6,9 @@ The Android application is a lightweight training-session recorder.
Its design priority is low-friction data entry during a workout. Its design priority is low-friction data entry during a workout.
## 2. Session flow It is not the canonical history or analytics application.
Expected flow: ## 2. Session flow
```text ```text
Start session Start session
@ -17,13 +17,16 @@ Start session
record started_at record started_at
| |
v v
add exercises and targets select/create exercise
|
v
enter target + planned rest
| |
v v
record actual sets record actual sets
| |
v v
optional body data optional body data / notes
| |
v v
Finish session Finish session
@ -32,45 +35,68 @@ Finish session
record ended_at record ended_at
| |
v v
export Trainlog JSON validate + export Trainlog JSON
``` ```
## 3. Exercise catalog ## 3. Exercise catalog
The application maintains a local exercise catalog for selection. The Android application keeps a local exercise catalog so names are not retyped every session.
The user must not need to retype the same exercise every session. Creating an exercise requires:
When a new exercise is created: - display name;
- tracking mode: repetitions or duration.
- generate a stable `exercise_id`; The application generates a stable `exercise_id`.
- store the display name;
- use that same identifier in future sessions;
- include the exercise catalog entry in exported files as required.
## 4. Planned work A new exercise used in an exported session is included in the top-level session export metadata and is therefore importable by the TUI.
The basic exercise form supports: The Android application must prevent accidental duplicate normalized names according to the Trainlog v1 contract.
- number of sets; ## 4. Fast exercise form
- repetitions or timed duration;
- load when relevant;
- rest duration.
Example: For a repetition exercise, the basic form is conceptually:
```text ```text
4 sets Exercise Presse à cuisses
5 repetitions Load mode External
80 kg Load 80 kg
60 seconds rest Sets 4
Repetitions 5
Rest 60 s
``` ```
## 5. Actual work For a timed exercise:
The UI should pre-populate performed sets from the target when convenient. ```text
Exercise Gainage ventral
Load mode None
Sets 3
Duration 45 s
Rest 60 s
```
The user only needs to edit differences. The application should remember practical defaults from the previous use of an exercise when that reduces typing, but remembered UI defaults are not part of the exchange-format contract.
## 5. Load modes
The user chooses only when relevant:
- none;
- external;
- assistance.
`external` covers free weights and machine-displayed load.
`assistance` stores a positive assistance value.
The UI should label assistance explicitly so it cannot be confused with added resistance.
## 6. Actual work
The application should pre-populate actual sets from the target.
The user edits only what differs.
Example target: Example target:
@ -84,30 +110,71 @@ Actual:
5 / 5 / 5 / 3 5 / 5 / 5 / 3
``` ```
The exported file must preserve both target and actual values. The export preserves both target and actual values.
## 6. Timestamps Zero actual repetitions are valid for a real failed attempt.
A planned exercise may also have zero actual sets if it was never started.
## 7. Rest
`rest_seconds` is the planned rest duration for the exercise.
v1 does not require a running rest timer and does not serialize measured per-set rest.
A timer can be added later as UI behavior without changing the v1 format.
## 8. Timestamps
`started_at` is recorded automatically when the session starts. `started_at` is recorded automatically when the session starts.
`ended_at` is recorded automatically when the session ends. `ended_at` is recorded automatically when the user finishes the session.
The UI may later allow explicit correction for forgotten starts or stops, but such correction must be visible to the user. An active/interrupted local session may exist without `ended_at`.
## 7. Export The application must never invent an end timestamp merely to make export validation pass.
The application exports valid Trainlog JSON. ## 9. Body data
It must not silently export malformed or incomplete data. Optional session-associated data:
The application must validate required fields before final export. - body weight;
- neck;
- shoulders;
- chest;
- waist;
- hips;
- left/right arm;
- left/right forearm;
- left/right thigh;
- left/right calf.
## 8. Non-goals The Android UI does not need to force these fields during every workout.
## 10. Notes
Session and exercise notes are optional.
The initial Android UI may omit note controls without violating v1, because the fields are optional.
## 11. Export
Before export, Android must enforce both:
- JSON structural validity;
- Trainlog v1 semantic validity.
A malformed or semantically inconsistent file must not be exported as a completed Trainlog document.
## 12. Non-goals
Initial Android versions do not need: Initial Android versions do not need:
- advanced analytics; - analytics;
- complex charts; - complex graphs;
- a cloud account; - cloud accounts;
- a remote database; - remote databases;
- social features. - social features;
- muscle classification;
- distance/cardio metrics;
- per-set rest measurement.

View file

@ -2,32 +2,53 @@
## 1. Status ## 1. Status
This document defines the Trainlog v1 exchange contract draft.
Current state:
```text ```text
TRAINLOG_FORMAT_V1=DRAFT
GATE_0=PASS GATE_0=PASS
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
``` ```
Incompatible changes are allowed until the format is explicitly marked `FROZEN`. This document defines the proposed final Trainlog v1 exchange contract.
Once frozen, incompatible changes require a new format version. The format remains `DRAFT` until Gate 1 validation and mirror review complete.
## 2. Encoding ## 2. Design goal
A Trainlog exchange document is: Trainlog v1 must represent the training patterns required by the initial applications without turning the Android recorder into a complex training platform.
The format supports:
- repetition-based exercises;
- time-based exercises;
- bodyweight work;
- free-weight and machine load;
- assistance load;
- planned versus performed work;
- planned rest;
- body weight;
- body measurements;
- active or interrupted sessions;
- optional notes.
Distance, velocity, heart rate, per-set measured rest, supersets, and arbitrary custom metrics are outside v1.
## 3. Encoding and strictness
A Trainlog v1 document is:
- JSON; - JSON;
- UTF-8; - UTF-8;
- one top-level JSON object. - one top-level object;
- structurally strict.
Unknown fields are rejected in v1. Unknown fields are rejected.
This strict rule is intentional: a misspelled or unsupported field must fail validation rather than be silently ignored. This is intentional. A misspelled field must fail validation instead of being silently discarded.
## 3. Required top-level fields ## 4. Top-level object
Required fields:
```json ```json
{ {
@ -38,89 +59,78 @@ This strict rule is intentional: a misspelled or unsupported field must fail val
} }
``` ```
### `format` `format` must equal `trainlog`.
Must equal: `version` must equal integer `1`.
```text The top-level `exercises` array contains metadata for exactly the exercises referenced by the session.
trainlog
```
### `version` It is not a full catalog synchronization document.
Must equal integer `1`. ## 5. Exercise identity
## 4. Exercise catalog
Each catalog entry contains: Each catalog entry contains:
```json ```json
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
} }
``` ```
### 4.1 Stable identity ### 5.1 `exercise_id`
`exercise_id` is the permanent machine identifier. `exercise_id` is the permanent machine identity.
Rules: Rules:
- ASCII lowercase identifier;
- 1 to 128 characters; - 1 to 128 characters;
- allowed characters: `a-z`, `0-9`, `_`, `-`; - ASCII lowercase;
- unique within one exchange document; - first character: `a-z` or `0-9`;
- must not change merely because the visible name changes. - remaining characters: `a-z`, `0-9`, `_`, `-`;
- unique inside the document;
- unchanged when the visible name changes.
The visible name is not the persistent identity. ### 5.2 `name`
### 4.2 Display-name anti-duplication rule `name` is the human-readable display name.
Two catalog entries must not have equivalent display names. The display name is not the identity.
For duplicate detection, implementations normalize names using this semantic algorithm: Two exercises in one document must not have equivalent normalized names.
Normalization for comparison is:
1. Unicode NFC normalization; 1. Unicode NFC normalization;
2. remove leading and trailing whitespace; 2. trim leading and trailing Unicode whitespace;
3. collapse each internal run of whitespace to one ASCII space; 3. collapse each internal whitespace run to one ASCII space;
4. Unicode case folding. 4. Unicode case folding.
Example: The serialized name is never rewritten by this normalization rule.
The future C implementation must use a Unicode implementation capable of reproducing this contract exactly; `utf8proc` or an equivalent tested implementation is acceptable.
### 5.3 `tracking_mode`
Every exercise has one stable tracking mode:
```text ```text
"Presse à cuisses" reps
" presse à cuisses " duration
"PRESSE À CUISSES"
``` ```
are considered the same display name. `reps` is used for repetition-counted exercises.
This rule prevents accidental duplicate exercises while still allowing an exercise to be renamed without changing `exercise_id`. `duration` is used for time-counted exercises such as planks.
JSON Schema cannot express this normalization rule. It is mandatory semantic validation. The tracking mode determines the Android input control and the interpretation of all sets for that exercise.
## 5. Session Changing the fundamental tracking mode of an existing exercise should normally create a new exercise identity rather than silently changing historical semantics.
Required fields: ## 6. Session identity
- `session_id`; A session contains a unique opaque `session_id`.
- `started_at`;
- `exercises`.
Optional fields:
- `ended_at`;
- `body_weight_kg`;
- `measurements`.
`ended_at` is optional because Trainlog may preserve an active or interrupted session.
A completed Android export normally includes `ended_at`.
The TUI must never invent an end timestamp for a session that does not have one.
## 6. Session identifier
Example: Example:
@ -132,18 +142,21 @@ Rules:
- 1 to 128 characters; - 1 to 128 characters;
- starts with an ASCII alphanumeric character; - starts with an ASCII alphanumeric character;
- remaining characters are ASCII alphanumeric, `_`, or `-`; - remaining characters are ASCII alphanumeric, `_`, or `-`.
- treated as an opaque unique identifier.
The generation algorithm is implementation-defined in v1. The generation algorithm remains implementation-defined.
The TUI enforces uniqueness in SQLite. The database uniqueness constraint is the final anti-duplication barrier.
Repeated import of the same `session_id` is idempotent. Importing an already-known `session_id` is idempotent.
## 7. Timestamps ## 7. Session timestamps
Timestamps use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset. `started_at` is required.
`ended_at` is optional.
Both use RFC 3339 / ISO 8601 date-time syntax with an explicit UTC offset.
Examples: Examples:
@ -152,19 +165,32 @@ Examples:
2026-09-05T16:34:12Z 2026-09-05T16:34:12Z
``` ```
An offset-less timestamp is invalid. Offset-less timestamps are invalid.
If `ended_at` is present, it must represent an instant strictly later than `started_at`. If `ended_at` exists, it must represent an instant strictly later than `started_at`.
Chronological ordering is a semantic validation rule. An absent `ended_at` means the session is still active, interrupted, or otherwise not formally completed.
## 8. Workout exercise entry Trainlog must not invent an end time.
Example: ## 8. Session exercise order
The order of `session.exercises` is meaningful.
It records the exercise order entered by the user.
The order of `sets` is also meaningful and defines performed set order.
No separate set number is serialized.
## 9. Planned work and performed work
Each session exercise contains:
```json ```json
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -184,60 +210,123 @@ Example:
`sets` describes actual performed work. `sets` describes actual performed work.
The two concepts must remain distinct. They must remain separate.
The number of actual sets is deliberately allowed to differ from `target.sets`. `target.sets` is the intended number of sets.
This records failure, extra work, interrupted sessions, and manual corrections truthfully. The length of `sets` is the actual number of recorded sets and may be:
## 9. Repetition mode and timed mode - smaller than the target;
- equal to the target;
- greater than the target;
- zero.
Each workout exercise has exactly one target mode: An empty actual-set array is valid for a planned exercise that was not performed.
- repetition mode: `reps`; ## 10. Repetition mode
- timed mode: `duration_seconds`.
A target must not contain both. For a `tracking_mode` of `reps`:
All actual sets for that exercise must use the same mode as the target. - `target.reps` is required;
- `target.duration_seconds` is forbidden;
- each actual set contains `reps`;
- each actual set forbids `duration_seconds`.
### Repetition example Target repetitions must be at least 1.
```json Actual repetitions may be 0.
{
"target": { A zero-repetition set represents a real attempted set with no completed repetition.
"sets": 4,
"reps": 5 A skipped set should normally be omitted instead.
}
} ## 11. Duration mode
For a `tracking_mode` of `duration`:
- `target.duration_seconds` is required;
- `target.reps` is forbidden;
- each actual set contains `duration_seconds`;
- each actual set forbids `reps`.
Duration values are positive integer seconds.
## 12. Load model
Each session exercise has exactly one `load_mode`:
```text
none
external
assistance
``` ```
### Timed example ### 12.1 `none`
```json Use for exercises where no separate load value is recorded.
{
"target": { Examples:
"sets": 3,
"duration_seconds": 45 - bodyweight squat;
} - unweighted plank;
} - push-up;
- pull-up without added or assisted load.
When `load_mode` is `none`, `weight_kg` is forbidden in the target and actual sets.
### 12.2 `external`
Use for a positive externally applied or machine-displayed load.
Examples:
- barbell;
- dumbbell;
- cable machine;
- leg press;
- weighted pull-up.
When `load_mode` is `external`:
- `target.weight_kg` is required;
- every actual set requires its own `weight_kg`.
Actual set load is stored per set so load changes remain representable.
### 12.3 `assistance`
Use when the numeric load represents assistance that reduces the effective difficulty of a bodyweight movement.
Example:
```text
Assisted pull-up: 20 kg assistance
``` ```
The schema rejects a set or target containing both `reps` and `duration_seconds`. When `load_mode` is `assistance`:
The target/actual mode-match rule is semantic validation. - `target.weight_kg` is required;
- every actual set requires its own `weight_kg`.
## 10. Load Assistance remains a positive value.
`weight_kg` represents external load in kilograms. Analytics must not treat increasing assistance as increasing strength.
It is optional because some exercises are bodyweight or duration-only exercises. ### 12.4 Unit and physical meaning
When supplied, actual-set load is recorded per set so a session can truthfully represent load changes between sets. All serialized loads use kilograms.
## 11. Rest For a machine, `weight_kg` records the load value displayed or declared by the machine/user.
`rest_seconds` is the planned rest duration after sets for the workout exercise. Trainlog does not claim that this value equals exact mechanical force at the body.
This distinction matters when comparing different machines.
## 13. Rest
`rest_seconds` is required for every session exercise.
It stores the planned rest interval in integer seconds.
Example: Example:
@ -247,93 +336,147 @@ Example:
means one minute. means one minute.
v1 does not record measured rest duration per individual set. Zero is valid when no planned rest exists.
That may be introduced only by an additive compatible extension before freeze or a later format version after freeze. v1 does not record measured rest between individual sets.
## 12. Body weight ## 14. Body weight
`body_weight_kg` is optional and uses kilograms. `body_weight_kg` is optional.
It represents body weight associated with the session. It is a positive kilogram value associated with the session timestamp.
Standalone body-weight observations outside a workout session are a TUI/database concern and do not require this session exchange object. The TUI database may also support standalone body-weight observations; those records are outside this session-exchange document.
## 13. Body measurements ## 15. Body measurements
Supported v1 measurements use centimeters: `measurements` is optional.
- `waist_cm`; If present, it contains at least one measurement.
- `chest_cm`;
- `shoulders_cm`;
- `left_arm_cm`;
- `right_arm_cm`;
- `left_thigh_cm`;
- `right_thigh_cm`;
- `left_calf_cm`;
- `right_calf_cm`.
If `measurements` is present, it must contain at least one measurement. All measurements are circumferences in centimeters unless the field name itself defines another interpretation.
Additional measurements may still be added before v1 is frozen. Frozen v1 measurement names proposed by Gate 1 review #1:
## 14. Catalog references ```text
neck_cm
shoulders_cm
chest_cm
waist_cm
hips_cm
left_arm_cm
right_arm_cm
left_forearm_cm
right_forearm_cm
left_thigh_cm
right_thigh_cm
left_calf_cm
right_calf_cm
```
Every `session.exercises[*].exercise_id` must reference an entry present in the top-level `exercises` catalog. `shoulders_cm` means shoulder-girdle circumference, not straight-line shoulder width.
This permits Android to introduce a new exercise safely during import. Left/right fields are intentionally separate so asymmetry can be followed over time.
An unknown reference makes the document invalid. ## 16. Notes
JSON Schema cannot express this cross-reference rule. It is mandatory semantic validation. Optional UTF-8 notes are supported at:
## 15. Duplicate workout exercise entries - session level;
- session-exercise level.
A session must not contain the same `exercise_id` more than once. A present note must not be empty or whitespace-only.
All performed sets for one exercise belong to its single workout entry. Session notes are limited to 4000 characters.
This keeps analysis and editing deterministic. Exercise notes are limited to 1000 characters.
## 16. Idempotent import Notes are user content and must be preserved exactly after validation.
The TUI treats `session_id` as a uniqueness key. ## 17. Catalog completeness
If the session is already present: The top-level exercise catalog must contain exactly the exercise identities referenced by `session.exercises`.
- do not create another session; Therefore:
- do not duplicate sets;
- do not partially merge the repeated document;
- report that the session already exists.
Database constraints are the final anti-duplication barrier. - every session exercise has one matching catalog entry;
- no catalog entry is unreferenced;
- an empty session exercise array requires an empty top-level exercise catalog.
## 17. Validation layers This keeps each session export self-contained without silently importing unrelated Android catalog entries.
A valid Trainlog v1 document must pass both: ## 18. One workout entry per exercise
A `session_id` may contain a given `exercise_id` at most once.
All performed sets for that exercise belong to its single session-exercise entry.
This makes editing and analytics deterministic.
## 19. Existing local exercise with different display name
Identity wins over display text.
If the TUI already knows an `exercise_id` and an imported session carries a different display name for that same identifier:
- the session may still import;
- the existing canonical TUI identity is used;
- the import must surface a non-fatal metadata warning;
- the session import must not silently rename the canonical local exercise.
Catalog synchronization and deliberate renaming are separate operations outside the v1 session-import transaction.
## 20. Structural and semantic validation
A valid v1 document passes both:
1. JSON Schema validation; 1. JSON Schema validation;
2. Trainlog semantic validation. 2. Trainlog semantic validation.
Schema validation handles structure and primitive bounds. Semantic rules include:
Semantic validation handles rules such as: - unique exercise identifiers;
- normalized display-name uniqueness;
- explicit timestamp offsets;
- timestamp chronology;
- exact catalog/reference set equality;
- one workout entry per exercise;
- catalog `tracking_mode` matching target and actual sets;
- `load_mode` matching weight presence;
- non-blank notes.
- normalized exercise-name uniqueness; ## 21. Explicit non-goals for v1
- exercise identifier uniqueness;
- catalog-reference integrity;
- duplicate workout exercise rejection;
- target/actual mode consistency;
- timestamp chronology.
Both Android export and TUI import must eventually implement the same semantic contract. The following are deliberately not represented by v1:
## 18. Freeze policy - distance;
- speed;
- velocity;
- heart rate;
- calories;
- measured per-set rest;
- supersets/circuits as first-class objects;
- arbitrary custom set metrics;
- muscle-group classification;
- machine seat/settings metadata;
- photos;
- cloud synchronization.
`TRAINLOG_FORMAT_V1` must not be marked `FROZEN` until: Those features can be introduced later without corrupting the simple initial recorder.
- all v1 fields are reviewed; ## 22. Freeze criteria
- valid fixtures pass;
- invalid fixtures fail for the intended reason; Gate 1 may freeze v1 only after:
- Android and TUI requirements contain no format ambiguity;
- the semantic validator contract is stable. - canonical valid fixtures pass;
- canonical invalid fixtures fail for the intended reason;
- Android requirements are aligned;
- TUI requirements are aligned;
- the schema and semantic contract contain no known ambiguity;
- the reviewed commit is pushed and mirrored.
Until then:
```text
TRAINLOG_FORMAT_V1=DRAFT
```

View file

@ -0,0 +1,146 @@
# Gate 1 Review #1 — Exchange Format Freeze Candidate
## Status
```text
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
## Purpose
Gate 1 review #1 turns the Gate 0 format skeleton into a complete v1 implementation contract.
No C17 or Android production implementation should depend on v1 until this review passes and the format is explicitly frozen.
## Decisions
### G1-R1-01 — Stable exercise tracking mode
Each exercise declares exactly one stable `tracking_mode`:
```text
reps
duration
```
This keeps Android and TUI forms deterministic.
### G1-R1-02 — Load semantics belong to the session exercise
A given movement may be bodyweight, externally loaded, or assisted on different sessions.
Therefore `load_mode` belongs to the session exercise, not permanently to the catalog.
Frozen candidates:
```text
none
external
assistance
```
### G1-R1-03 — Assistance is not resistance
Positive assistance kilograms are stored as positive values.
Analytics must interpret higher assistance as more help, not more strength.
### G1-R1-04 — Actual load is per set
Loaded exercises require `weight_kg` on every performed set.
This permits truthful representation of drop sets, changed machine loads, and failed attempts.
### G1-R1-05 — Failed zero-repetition attempts are representable
Actual `reps` may equal zero.
Target `reps` remain strictly positive.
A skipped set is omitted; zero represents an actual attempt with no completed repetition.
### G1-R1-06 — Planned exercise with no actual sets is representable
`sets` may be empty.
This supports interrupted sessions and exercises planned but never started.
### G1-R1-07 — Session and set order are array order
No redundant serialized ordinal is needed.
Array order is canonical order.
### G1-R1-08 — Measurement list is explicit
The proposed final v1 list is:
- neck;
- shoulders;
- chest;
- waist;
- hips;
- left/right arm;
- left/right forearm;
- left/right thigh;
- left/right calf.
### G1-R1-09 — Notes are optional and bounded
Session notes: maximum 4000 characters.
Exercise notes: maximum 1000 characters.
Whitespace-only notes are semantically invalid.
### G1-R1-10 — Catalog metadata is session-scoped, not full synchronization
The top-level catalog contains exactly the exercise IDs referenced by the exported session.
This prevents an ordinary session import from silently importing unrelated Android catalog entries.
### G1-R1-11 — Existing ID/name mismatch is non-fatal
Stable identity wins over display metadata.
A session may import using an existing ID while surfacing a metadata warning.
Session import does not silently rename the canonical local exercise.
### G1-R1-12 — v1 non-goals are explicit
Distance, cardio telemetry, custom metrics, supersets, machine settings, muscle classification, and measured per-set rest are intentionally deferred.
## Validation
Run:
```bash
python tools/validate_json.py
git diff --check
```
The suite must include valid examples of:
- loaded repetition exercise;
- unweighted timed exercise;
- active session;
- zero-repetition failed attempt;
- assistance load;
- empty interrupted session.
The invalid suite must cover all frozen semantic boundaries.
## Freeze decision
This review is a freeze candidate, not the freeze itself.
After canonical validation, commit, push, and mirrored review, a separate closure patch may set:
```text
GATE_1_REVIEW_01=PASS
GATE_1=PASS
TRAINLOG_FORMAT_V1=FROZEN
```

View file

@ -11,59 +11,67 @@ GATE_0=PASS
GATE_0_REVIEW_01=PASS GATE_0_REVIEW_01=PASS
``` ```
Deliverables: Gate 0 established:
- repository structure; - repository structure;
- development contract; - development contract;
- architecture documentation; - architecture documentation;
- coding-style documentation; - coding-style rules;
- exchange-format v1 draft; - exchange-format draft;
- JSON Schema draft; - structural and semantic validation;
- valid example fixture; - positive and negative fixture strategy.
- semantic validator;
- positive and negative fixture suite;
- Gate 0 review report.
Exit criteria: Reviewed hardening commit:
- documentation reviewed; ```text
- JSON example validates; bc54d6b4ce10d098916823b6a79f72b39d9c7703
- valid fixtures are accepted; ```
- invalid fixtures are rejected;
- semantic invariants are documented;
- repository clean after commit.
Gate 0 passed after: Gate 0 closure commit:
- canonical local fixture validation succeeded; ```text
- `git diff --check` succeeded; ebd4316ed68c58598a471e567edf13455d00f92b
- review commit `bc54d6b4ce10d098916823b6a79f72b39d9c7703` was pushed; ```
- the GitHub mirror was independently read back and reviewed.
Gate 1 is now the active gate.
## Gate 1 — Exchange format v1 freeze ## Gate 1 — Exchange format v1 freeze
Deliverables: Status: VALIDATION PENDING — REVIEW #1
- complete field list; Canonical state:
- exercise identity rules;
- session identity rules; ```text
- repetitions and timed-set representation; GATE_1_REVIEW_01=IMPLEMENTED
- rest representation; GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
Review #1 freezes the proposed model for:
- exercise identity;
- stable repetition/duration tracking mode;
- session identity;
- session ordering;
- external/none/assistance load semantics;
- planned versus actual sets;
- zero-repetition failed attempts;
- planned exercises with zero actual sets;
- planned rest;
- body weight; - body weight;
- measurement list; - final v1 body-measurement field list;
- strict unknown-field policy; - optional notes;
- valid fixture suite; - strict catalog completeness;
- invalid fixture suite; - strict unknown-field behavior.
- stable semantic-validation contract.
Exit criteria: Exit criteria:
- `TRAINLOG_FORMAT_V1=FROZEN`; - all valid fixtures accepted;
- schema tests pass; - all invalid fixtures rejected for the intended reason;
- semantic tests pass; - Android documentation aligned;
- Android and TUI can implement against the contract without ambiguity. - TUI documentation aligned;
- schema and semantic validator aligned;
- review commit pushed to Forgejo and GitHub;
- mirrored review passes;
- `TRAINLOG_FORMAT_V1=FROZEN`.
## Gate 2 — TUI persistence core ## Gate 2 — TUI persistence core

View file

@ -4,23 +4,11 @@
A feature is not complete without relevant validation. A feature is not complete without relevant validation.
The exchange-format validator is part of the executable contract during early development. The exchange validator is executable specification during the format gates.
## 2. Validation layers ## 2. Canonical exchange validation
Trainlog uses or will use: Run:
- JSON Schema validation;
- Trainlog semantic validation;
- unit tests;
- integration tests;
- database constraint tests;
- TUI smoke tests;
- sanitizer builds where practical.
## 3. Exchange-format validation
Run the canonical suite with:
```bash ```bash
python tools/validate_json.py python tools/validate_json.py
@ -29,83 +17,97 @@ python tools/validate_json.py
The command validates: The command validates:
- `examples/session-v1.json`; - `examples/session-v1.json`;
- every file in `tests/fixtures/valid/` as valid; - all `tests/fixtures/valid/*.json` as valid;
- every file in `tests/fixtures/invalid/` as invalid. - all `tests/fixtures/invalid/*.json` as invalid.
A negative fixture passes only when validation rejects it. A negative fixture passes only when Trainlog rejects it.
## 4. Structural versus semantic validation ## 3. Validation layers
JSON Schema validates structure and primitive bounds. A v1 document must pass:
`tools/validate_json.py` additionally validates rules JSON Schema cannot safely express, including: 1. JSON Schema validation;
2. Trainlog semantic validation.
- unique `exercise_id` values; Schema handles shape, enumerations, and primitive ranges.
- normalized display-name uniqueness;
- catalog-reference integrity; Semantic validation handles cross-object and normalized rules.
- one workout entry per exercise;
- target/actual mode consistency; ## 4. Gate 1 semantic coverage
The canonical validator checks:
- unique `exercise_id`;
- normalized exercise-name uniqueness;
- explicit timestamp offsets; - explicit timestamp offsets;
- end-time chronology. - end time later than start time;
- exact catalog/reference set equality;
- one workout entry per exercise;
- catalog tracking mode matching target;
- catalog tracking mode matching actual sets;
- load-mode/weight consistency;
- non-blank notes.
Android export and TUI import must eventually implement the same semantic rules. ## 5. Positive fixture coverage
## 5. Initial invalid fixture coverage Gate 1 includes:
The Gate 0 suite covers: - mixed loaded repetition + timed session;
- active session without `ended_at`;
- planned exercise with zero actual sets;
- bodyweight exercise with zero-repetition failed attempt;
- assistance load;
- completely interrupted session with zero exercises.
- duplicate exercise identifiers; ## 6. Negative fixture coverage
Gate 1 includes rejection of:
- duplicate exercise IDs;
- duplicate normalized exercise names; - duplicate normalized exercise names;
- unknown exercise references;
- duplicate workout exercise entries; - duplicate workout exercise entries;
- end timestamp before start timestamp; - end timestamp before start;
- offset-less timestamp; - missing timestamp offset;
- target/actual mode mismatch; - target/actual tracking mismatch;
- target containing both repetitions and duration; - target containing both repetitions and duration;
- unknown JSON field. - unknown exercise reference;
- unknown JSON field;
- catalog tracking-mode mismatch;
- `load_mode=none` carrying weight;
- loaded target missing weight;
- loaded actual set missing weight;
- unreferenced catalog entries;
- blank session notes;
- blank exercise notes;
- negative actual repetitions.
## 6. Database tests ## 7. Future database validation
Future tests must verify: Gate 2 must verify:
- foreign keys are active; - foreign keys enabled;
- duplicate `session_id` is rejected or handled idempotently; - duplicate `session_id` idempotency;
- duplicate `exercise_id` is rejected; - duplicate `exercise_id` barrier;
- failed imports roll back completely; - transactional rollback;
- migrations preserve data. - schema migration correctness.
## 7. C validation ## 8. Future C validation
Initial C validation will include: C implementation gates will include:
```text - normal build;
normal build - strict-warning build;
strict warning build - ASan/UBSan build;
ASan/UBSan build - formatter check;
``` - relevant unit/integration tests.
Exact commands will be frozen when `meson.build` exists.
## 8. TUI tests
At minimum:
- application starts in a supported terminal;
- small-terminal fallback works;
- navigation does not corrupt state;
- UTF-8 labels render correctly;
- color roles render correctly;
- monochrome fallback remains understandable.
## 9. Pre-push checklist ## 9. Pre-push checklist
Before a meaningful push: Before every meaningful push:
1. format code; 1. run `python tools/validate_json.py`;
2. run `python tools/validate_json.py`; 2. run relevant compiled tests when available;
3. build when buildable code exists; 3. run sanitizers when relevant;
4. run relevant tests; 4. run `git diff --check`;
5. run sanitizer suite when relevant; 5. inspect `git status --short`;
6. run `git diff --check`; 6. review documentation changes.
7. inspect `git status --short`;
8. update documentation.

View file

@ -2,7 +2,7 @@
## 1. Purpose ## 1. Purpose
The Trainlog TUI is the primary history, analysis, and visualization application. The Trainlog TUI is the primary local history, analysis, and visualization application.
It is implemented in C17 with `ncursesw`. It is implemented in C17 with `ncursesw`.
@ -17,30 +17,28 @@ Initial screen plan:
- Body; - Body;
- Import. - Import.
The final key bindings will be frozen before implementation.
## 3. Color ## 3. Color
The TUI should be visually rich but remain readable. The TUI is intentionally colorful.
Color is used to reinforce meaning, never as the only indicator. Color reinforces meaning but is never the sole indicator.
Conceptual roles: Conceptual roles:
- accent: titles, active selection, highlighted metrics; - accent: titles and current selection;
- success: completed target; - success: completed target;
- warning: partial target or attention state; - warning: partial target or attention state;
- error: invalid input or failed operation; - error: invalid input or failed operation;
- muted: secondary information; - muted: secondary information;
- graph series: consistent distinguishable colors. - graph series: consistent distinguishable colors.
All color pairs must be centralized in a theme module. All color pairs must be centralized in a dedicated theme module.
Do not scatter raw `COLOR_*` decisions throughout screens. Raw screen code must not scatter `COLOR_*` decisions.
## 4. Monochrome fallback ## 4. Monochrome fallback
Every meaningful color state must also have a textual or symbolic representation. Meaningful states also use text or symbols.
Examples: Examples:
@ -51,60 +49,98 @@ x failed
> selected > selected
``` ```
## 5. UTF-8 ## 5. UTF-8 and exercise-name normalization
The TUI uses wide-character ncurses support. The TUI uses wide-character ncurses support and initializes locale before ncurses.
The implementation must initialize locale correctly before ncurses use. Trainlog v1 duplicate-name validation requires Unicode NFC normalization, whitespace normalization, and Unicode case folding.
Rendering must be tested with accented French text and common symbols. The C implementation must use a tested Unicode library or equivalent implementation that reproduces the v1 contract exactly.
## 6. Dashboard A likely implementation dependency is `utf8proc`; the final dependency choice is frozen before the relevant C module is implemented.
## 6. Exercise catalog
Each exercise stores:
- stable `exercise_id`;
- mutable display name;
- stable `tracking_mode` (`reps` or `duration`).
The TUI uses this metadata to select the correct data-entry control.
A session import may introduce a previously unknown exercise.
If an existing ID arrives with a different display name, the session may import but the TUI must surface a metadata warning and must not silently rename the canonical local exercise.
## 7. New session
The TUI can record a workout directly using the same logical exercise model as Android.
Per exercise:
- exercise;
- load mode;
- target sets;
- target repetitions or duration;
- target load when applicable;
- planned rest;
- actual sets;
- optional note.
## 8. Load semantics
The TUI must distinguish:
- no separate load;
- external resistance;
- assistance.
Analytics must not rank assistance as though more assistance represented more strength.
Machine-displayed kilograms are stored faithfully but must not be presented as exact cross-machine mechanical equivalence.
## 9. Dashboard
The dashboard should eventually show: The dashboard should eventually show:
- current body weight; - current body weight;
- recent body-weight change; - recent weight change;
- number of sessions in a selected period; - sessions in a selected period;
- total training duration; - total training duration;
- recent performance highlights; - recent performance highlights;
- compact terminal graphs. - compact terminal graphs.
## 7. New session ## 10. Body tracking
The TUI must be able to record a workout directly, using the same logical exercise catalog as Android. The TUI database may store standalone body observations independently from workout imports.
A typical exercise form contains: The session exchange format can also attach weight and measurements to one session timestamp.
- exercise; Body-trend graphs operate on the canonical database representation, not directly on raw JSON files.
- target sets;
- target repetitions or duration;
- target load when relevant;
- rest duration;
- actual performed sets.
## 8. Graphs ## 11. Graphs
Graphs must be terminal-native. Terminal-native graph targets include:
Possible graph types: - body-weight trend;
- weight trend;
- measurement trend; - measurement trend;
- exercise load trend; - external-load trend;
- estimated or measured max trend; - measured or estimated maximum trend;
- training volume trend. - training-volume trend.
Graphs should adapt to terminal size. Assistance exercises require direction-aware analytics.
## 9. Minimum terminal size ## 12. Minimum terminal size
A minimum supported terminal size will be defined during the first TUI milestone. A minimum supported terminal size will be defined during the first TUI milestone.
Below the minimum size, Trainlog must display a clear message instead of rendering a broken layout. Below that size, Trainlog displays a clear fallback message rather than a corrupted layout.
## 10. Input safety ## 13. Input safety
The TUI must validate numeric fields before committing data. Numeric input is validated before persistent state is committed.
Invalid input must not partially modify persistent state. Invalid input must never partially mutate a saved session.
Imports use full validation before the database transaction commits.

View file

@ -4,11 +4,13 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
@ -17,14 +19,19 @@
"ended_at": "2026-09-05T19:27:48+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4, "body_weight_kg": 82.4,
"measurements": { "measurements": {
"waist_cm": 91.0, "neck_cm": 39.0,
"chest_cm": 104.0, "chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0, "left_arm_cm": 35.0,
"right_arm_cm": 35.0 "right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
}, },
"notes": "Séance jambes et gainage.",
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -52,6 +59,7 @@
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 3, "sets": 3,

View file

@ -1,19 +1,24 @@
# Trainlog Format # Trainlog Format
This directory contains machine-readable definitions of the Trainlog exchange format. This directory contains the machine-readable Trainlog exchange contract.
Current draft: Current canonical state:
- `trainlog-v1.schema.json` ```text
GATE_0=PASS
GATE_1_REVIEW_01=IMPLEMENTED
GATE_1=VALIDATION_PENDING
TRAINLOG_FORMAT_V1=DRAFT
```
Canonical human-readable semantics live in: Files:
- `docs/exchange_format.md` - `trainlog-v1.schema.json`: structural JSON Schema;
- `../docs/exchange_format.md`: canonical semantic specification;
- `../tools/validate_json.py`: executable structural + semantic validator.
The schema is a structural validator. The JSON Schema alone is not the complete Trainlog contract.
Semantic rules that JSON Schema cannot safely express remain documented and must be tested in application code. A document is valid only when it passes both structural and semantic validation.
Before Trainlog v1 is frozen, schema changes are allowed. After `TRAINLOG_FORMAT_V1=FROZEN`, incompatible changes require a new exchange-format version.
After `TRAINLOG_FORMAT_V1=FROZEN`, incompatible changes require a new version.

View file

@ -19,6 +19,7 @@
}, },
"exercises": { "exercises": {
"type": "array", "type": "array",
"maxItems": 1000,
"items": { "items": {
"$ref": "#/$defs/exerciseCatalogEntry" "$ref": "#/$defs/exerciseCatalogEntry"
} }
@ -40,12 +41,18 @@
"maxLength": 128, "maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$" "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
}, },
"note": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"exerciseCatalogEntry": { "exerciseCatalogEntry": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"exercise_id", "exercise_id",
"name" "name",
"tracking_mode"
], ],
"properties": { "properties": {
"exercise_id": { "exercise_id": {
@ -55,16 +62,23 @@
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": 200 "maxLength": 200
},
"tracking_mode": {
"type": "string",
"enum": [
"reps",
"duration"
]
} }
} }
}, },
"set": { "actualSet": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"reps": { "reps": {
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 0,
"maximum": 10000 "maximum": 10000
}, },
"duration_seconds": { "duration_seconds": {
@ -74,7 +88,7 @@
}, },
"weight_kg": { "weight_kg": {
"type": "number", "type": "number",
"minimum": 0, "exclusiveMinimum": 0,
"maximum": 5000 "maximum": 5000
} }
}, },
@ -125,7 +139,7 @@
}, },
"weight_kg": { "weight_kg": {
"type": "number", "type": "number",
"minimum": 0, "exclusiveMinimum": 0,
"maximum": 5000 "maximum": 5000
} }
}, },
@ -157,6 +171,7 @@
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"exercise_id", "exercise_id",
"load_mode",
"rest_seconds", "rest_seconds",
"target", "target",
"sets" "sets"
@ -165,6 +180,14 @@
"exercise_id": { "exercise_id": {
"$ref": "#/$defs/exerciseId" "$ref": "#/$defs/exerciseId"
}, },
"load_mode": {
"type": "string",
"enum": [
"none",
"external",
"assistance"
]
},
"rest_seconds": { "rest_seconds": {
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,
@ -175,11 +198,15 @@
}, },
"sets": { "sets": {
"type": "array", "type": "array",
"minItems": 1,
"maxItems": 1000, "maxItems": 1000,
"items": { "items": {
"$ref": "#/$defs/set" "$ref": "#/$defs/actualSet"
} }
},
"notes": {
"type": "string",
"minLength": 1,
"maxLength": 1000
} }
} }
}, },
@ -188,7 +215,12 @@
"additionalProperties": false, "additionalProperties": false,
"minProperties": 1, "minProperties": 1,
"properties": { "properties": {
"waist_cm": { "neck_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"shoulders_cm": {
"type": "number", "type": "number",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 500 "maximum": 500
@ -198,7 +230,12 @@
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 500 "maximum": 500
}, },
"shoulders_cm": { "waist_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"hips_cm": {
"type": "number", "type": "number",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 500 "maximum": 500
@ -213,6 +250,16 @@
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
"maximum": 500 "maximum": 500
}, },
"left_forearm_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"right_forearm_cm": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 500
},
"left_thigh_cm": { "left_thigh_cm": {
"type": "number", "type": "number",
"exclusiveMinimum": 0, "exclusiveMinimum": 0,
@ -263,9 +310,11 @@
"measurements": { "measurements": {
"$ref": "#/$defs/measurements" "$ref": "#/$defs/measurements"
}, },
"notes": {
"$ref": "#/$defs/note"
},
"exercises": { "exercises": {
"type": "array", "type": "array",
"minItems": 1,
"maxItems": 1000, "maxItems": 1000,
"items": { "items": {
"$ref": "#/$defs/sessionExercise" "$ref": "#/$defs/sessionExercise"

View file

@ -0,0 +1,82 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
],
"notes": "\t\n"
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,82 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
],
"notes": " "
}
}

View file

@ -4,24 +4,38 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
}, },
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Autre presse" "name": "Presse à cuisses",
"tracking_mode": "reps"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -36,6 +50,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -4,24 +4,38 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
}, },
{ {
"exercise_id": "leg_press_alt", "exercise_id": "leg_press_alt",
"name": " PRESSE À CUISSES " "name": " PRESSE À CUISSES ",
"tracking_mode": "reps"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -36,6 +50,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -32,11 +45,40 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
}, },
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -51,6 +93,14 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
} }
] ]
} }

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T17:59:59+02:00", "ended_at": "2026-09-05T17:00:00+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -32,6 +45,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -0,0 +1,80 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,80 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,81 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -0,0 +1,81 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": -1,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -26,11 +39,40 @@
}, },
"sets": [ "sets": [
{ {
"duration_seconds": 30 "duration_seconds": 30,
"weight_kg": 80
}, },
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -33,6 +46,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00", "started_at": "2026-09-05T18:34:12",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -32,6 +45,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -0,0 +1,81 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "duration"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "unknown_exercise", "exercise_id": "unknown_exercise",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -32,6 +45,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -4,20 +4,33 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"name": "Presse à cuisses" "name": "Presse à cuisses",
"tracking_mode": "reps"
}, },
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
"session_id": "fixture-session-1", "session_id": "invalid-base",
"started_at": "2026-09-05T18:00:00+02:00", "started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:00:00+02:00", "ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [ "exercises": [
{ {
"exercise_id": "leg_press", "exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 4, "sets": 4,
@ -32,6 +45,34 @@
{ {
"reps": 5, "reps": 5,
"weight_kg": 80 "weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
} }
] ]
} }

View file

@ -0,0 +1,86 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "leg_press",
"name": "Presse à cuisses",
"tracking_mode": "reps"
},
{
"exercise_id": "plank",
"name": "Gainage ventral",
"tracking_mode": "duration"
},
{
"exercise_id": "unused",
"name": "Exercice inutilisé",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "invalid-base",
"started_at": "2026-09-05T18:34:12+02:00",
"ended_at": "2026-09-05T19:27:48+02:00",
"body_weight_kg": 82.4,
"measurements": {
"neck_cm": 39.0,
"chest_cm": 104.0,
"waist_cm": 91.0,
"left_arm_cm": 35.0,
"right_arm_cm": 35.0,
"left_thigh_cm": 58.0,
"right_thigh_cm": 57.0
},
"exercises": [
{
"exercise_id": "leg_press",
"load_mode": "external",
"rest_seconds": 60,
"target": {
"sets": 4,
"reps": 5,
"weight_kg": 80
},
"sets": [
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 5,
"weight_kg": 80
},
{
"reps": 3,
"weight_kg": 80
}
]
},
{
"exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60,
"target": {
"sets": 3,
"duration_seconds": 45
},
"sets": [
{
"duration_seconds": 45
},
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
}
]
}
}

View file

@ -4,7 +4,8 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "plank", "exercise_id": "plank",
"name": "Gainage ventral" "name": "Gainage ventral",
"tracking_mode": "duration"
} }
], ],
"session": { "session": {
@ -13,19 +14,13 @@
"exercises": [ "exercises": [
{ {
"exercise_id": "plank", "exercise_id": "plank",
"load_mode": "none",
"rest_seconds": 60, "rest_seconds": 60,
"target": { "target": {
"sets": 3, "sets": 3,
"duration_seconds": 45 "duration_seconds": 45
}, },
"sets": [ "sets": []
{
"duration_seconds": 45
},
{
"duration_seconds": 38
}
]
} }
] ]
} }

View file

@ -0,0 +1,42 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "assisted_pull_up",
"name": "Traction assistée",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "assisted-pull-up",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T18:15:00+02:00",
"exercises": [
{
"exercise_id": "assisted_pull_up",
"load_mode": "assistance",
"rest_seconds": 90,
"target": {
"sets": 3,
"reps": 8,
"weight_kg": 20
},
"sets": [
{
"reps": 8,
"weight_kg": 20
},
{
"reps": 8,
"weight_kg": 20
},
{
"reps": 6,
"weight_kg": 20
}
]
}
]
}
}

View file

@ -0,0 +1,32 @@
{
"format": "trainlog",
"version": 1,
"exercises": [
{
"exercise_id": "pull_up",
"name": "Traction",
"tracking_mode": "reps"
}
],
"session": {
"session_id": "zero-rep-attempt",
"started_at": "2026-09-05T18:00:00+02:00",
"ended_at": "2026-09-05T18:10:00+02:00",
"exercises": [
{
"exercise_id": "pull_up",
"load_mode": "none",
"rest_seconds": 120,
"target": {
"sets": 1,
"reps": 1
},
"sets": [
{
"reps": 0
}
]
}
]
}
}

View file

@ -0,0 +1,11 @@
{
"format": "trainlog",
"version": 1,
"exercises": [],
"session": {
"session_id": "interrupted-before-first-exercise",
"started_at": "2026-09-05T18:00:00+02:00",
"notes": "Séance interrompue avant le premier exercice.",
"exercises": []
}
}

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Validate Trainlog JSON documents structurally and semantically.""" """Validate Trainlog v1 JSON documents structurally and semantically."""
from __future__ import annotations from __future__ import annotations
@ -29,7 +29,7 @@ INVALID_FIXTURE_DIR = ROOT / "tests" / "fixtures" / "invalid"
class TrainlogSemanticError(ValueError): class TrainlogSemanticError(ValueError):
"""Raised when structurally valid JSON violates Trainlog semantics.""" """Raised when structurally valid JSON violates Trainlog v1 semantics."""
def load_json(path: Path) -> Any: def load_json(path: Path) -> Any:
@ -39,11 +39,7 @@ def load_json(path: Path) -> Any:
def normalize_exercise_name(name: str) -> str: def normalize_exercise_name(name: str) -> str:
"""Return the canonical comparison form used for duplicate-name checks. """Return the canonical comparison form for duplicate-name validation."""
The serialized display name is never rewritten by this function. The
normalized value exists only for semantic identity checks.
"""
nfc = unicodedata.normalize("NFC", name) nfc = unicodedata.normalize("NFC", name)
collapsed = " ".join(nfc.strip().split()) collapsed = " ".join(nfc.strip().split())
return collapsed.casefold() return collapsed.casefold()
@ -70,24 +66,96 @@ def parse_timestamp(value: str, field_name: str) -> datetime:
return parsed return parsed
def require_non_blank(value: str, field_name: str) -> None:
"""Reject a note that contains only Unicode whitespace."""
if not value.strip():
raise TrainlogSemanticError(f"{field_name}: must not be blank")
def validate_load_mode(
workout: dict[str, Any],
workout_index: int,
) -> None:
"""Validate load-mode rules for one session exercise."""
load_mode = workout["load_mode"]
target = workout["target"]
actual_sets = workout["sets"]
target_has_weight = "weight_kg" in target
if load_mode == "none":
if target_has_weight:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].target.weight_kg: "
"forbidden when load_mode is 'none'"
)
for set_index, actual_set in enumerate(actual_sets):
if "weight_kg" in actual_set:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].sets[{set_index}]."
"weight_kg: forbidden when load_mode is 'none'"
)
return
if not target_has_weight:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].target.weight_kg: "
f"required when load_mode is {load_mode!r}"
)
for set_index, actual_set in enumerate(actual_sets):
if "weight_kg" not in actual_set:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].sets[{set_index}]."
f"weight_kg: required when load_mode is {load_mode!r}"
)
def validate_tracking_mode(
workout: dict[str, Any],
workout_index: int,
tracking_mode: str,
) -> None:
"""Validate target and actual-set metric fields against catalog mode."""
target = workout["target"]
target_mode = "reps" if "reps" in target else "duration"
if target_mode != tracking_mode:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].target: mode "
f"{target_mode!r} does not match catalog tracking_mode "
f"{tracking_mode!r}"
)
for set_index, actual_set in enumerate(workout["sets"]):
actual_mode = "reps" if "reps" in actual_set else "duration"
if actual_mode != tracking_mode:
raise TrainlogSemanticError(
f"session.exercises[{workout_index}].sets[{set_index}]: "
f"mode {actual_mode!r} does not match catalog tracking_mode "
f"{tracking_mode!r}"
)
def validate_semantics(document: dict[str, Any]) -> None: def validate_semantics(document: dict[str, Any]) -> None:
"""Validate cross-field and normalized Trainlog v1 invariants.""" """Validate Trainlog v1 cross-field and normalized invariants."""
catalog = document["exercises"] catalog = document["exercises"]
session = document["session"] session = document["session"]
exercise_ids: set[str] = set() catalog_by_id: dict[str, dict[str, Any]] = {}
normalized_names: dict[str, str] = {} normalized_names: dict[str, str] = {}
for index, exercise in enumerate(catalog): for index, exercise in enumerate(catalog):
exercise_id = exercise["exercise_id"] exercise_id = exercise["exercise_id"]
name = exercise["name"] name = exercise["name"]
if exercise_id in exercise_ids: if exercise_id in catalog_by_id:
raise TrainlogSemanticError( raise TrainlogSemanticError(
f"exercises[{index}].exercise_id: duplicate exercise_id " f"exercises[{index}].exercise_id: duplicate exercise_id "
f"{exercise_id!r}" f"{exercise_id!r}"
) )
exercise_ids.add(exercise_id) catalog_by_id[exercise_id] = exercise
normalized = normalize_exercise_name(name) normalized = normalize_exercise_name(name)
if not normalized: if not normalized:
@ -112,12 +180,16 @@ def validate_semantics(document: dict[str, Any]) -> None:
"session.ended_at: must be strictly later than session.started_at" "session.ended_at: must be strictly later than session.started_at"
) )
if "notes" in session:
require_non_blank(session["notes"], "session.notes")
workout_ids: set[str] = set() workout_ids: set[str] = set()
for index, workout in enumerate(session["exercises"]): for index, workout in enumerate(session["exercises"]):
exercise_id = workout["exercise_id"] exercise_id = workout["exercise_id"]
if exercise_id not in exercise_ids: catalog_entry = catalog_by_id.get(exercise_id)
if catalog_entry is None:
raise TrainlogSemanticError( raise TrainlogSemanticError(
f"session.exercises[{index}].exercise_id: unknown catalog " f"session.exercises[{index}].exercise_id: unknown catalog "
f"reference {exercise_id!r}" f"reference {exercise_id!r}"
@ -130,21 +202,31 @@ def validate_semantics(document: dict[str, Any]) -> None:
) )
workout_ids.add(exercise_id) workout_ids.add(exercise_id)
target = workout["target"] validate_tracking_mode(
target_mode = ( workout,
"reps" if "reps" in target else "duration_seconds" index,
catalog_entry["tracking_mode"],
) )
validate_load_mode(workout, index)
for set_index, actual_set in enumerate(workout["sets"]): if "notes" in workout:
actual_mode = ( require_non_blank(
"reps" if "reps" in actual_set else "duration_seconds" workout["notes"],
f"session.exercises[{index}].notes",
) )
if actual_mode != target_mode:
raise TrainlogSemanticError( catalog_ids = set(catalog_by_id)
f"session.exercises[{index}].sets[{set_index}]: " if catalog_ids != workout_ids:
f"actual mode {actual_mode!r} does not match target mode " unreferenced = sorted(catalog_ids - workout_ids)
f"{target_mode!r}" missing = sorted(workout_ids - catalog_ids)
) details: list[str] = []
if unreferenced:
details.append(f"unreferenced catalog ids: {unreferenced}")
if missing:
details.append(f"missing catalog ids: {missing}")
raise TrainlogSemanticError(
"catalog/reference set mismatch: " + "; ".join(details)
)
def structural_errors( def structural_errors(
@ -171,7 +253,7 @@ def validate_document(
validator: jsonschema.Draft202012Validator, validator: jsonschema.Draft202012Validator,
path: Path, path: Path,
) -> list[str]: ) -> list[str]:
"""Return all validation errors for one Trainlog document.""" """Return validation errors for one Trainlog document."""
try: try:
document = load_json(path) document = load_json(path)
except (OSError, json.JSONDecodeError) as exc: except (OSError, json.JSONDecodeError) as exc:
@ -202,6 +284,10 @@ def run_suite(validator: jsonschema.Draft202012Validator) -> int:
valid, invalid = discover_suite() valid, invalid = discover_suite()
failed = False failed = False
if not valid:
print("FAIL test suite: no valid fixtures found")
return 1
if not invalid: if not invalid:
print("FAIL test suite: no invalid fixtures found") print("FAIL test suite: no invalid fixtures found")
return 1 return 1