Add first usable Trainlog TUI

This commit is contained in:
fy59 2026-09-05 19:59:55 +02:00
parent 3d5f9a2bef
commit 3e36b89d6b
25 changed files with 4081 additions and 208 deletions

View file

@ -2,35 +2,28 @@
All notable changes to Trainlog will be documented in this file. All notable changes to Trainlog will be documented in this file.
The project uses a simple pre-release changelog during early development.
## Unreleased ## Unreleased
### Added ### Added
- Initial repository structure and development contract. - Frozen Trainlog JSON v1 contract.
- Architecture, coding-style, database, Android, TUI, testing, and roadmap documentation. - SQLite persistence foundation.
- Trainlog v1 JSON Schema draft. - UUIDv4 Trainlog identifiers.
- Structural and semantic Trainlog validator. - Frozen Unicode exercise-name normalization using utf8proc.
- Positive and negative exchange-format fixtures. - Direct exercise creation.
- Gate 0 review and closure records. - Direct workout recording in the TUI.
- Gate 1 exchange-format freeze-candidate review. - Automatic workout start/end timestamps.
- Gate 1 exercise-identity collision review. - Repetition and timed exercises.
- Stable exercise tracking modes. - None/external/assistance load entry.
- Explicit load modes for no load, external resistance, and assistance. - Per-set actual performance entry.
- Optional bounded session and exercise notes. - Planned rest entry.
- Extended body measurement list. - Body weight and common body measurement entry.
- Executable local catalog reconciliation contract. - Session history.
- UUIDv4 generation policy for new Trainlog exercise and session IDs. - Colorful ncursesw dashboard.
- Compact terminal body-weight sparkline.
- Catalog and persistence tests.
### Changed ### Changed
- Gate 0 project contract is complete. - Development switches to larger vertical slices for the Monday 2026-09-07 05:00 usable-version target.
- Gate 1 completed and Trainlog exchange format v1 frozen. - Minimal TUI work is pulled forward before Android so a functional fallback exists.
- Actual repetition count may be zero for a failed attempt.
- Planned exercises may contain zero actual sets.
- Top-level exercise metadata must exactly match session exercise references.
- Assistance kilograms have distinct semantics from external resistance.
- Different exercise IDs with equivalent normalized names are hard import conflicts.
- Same exercise ID with incompatible tracking mode is a hard import conflict.
- `TRAINLOG_FORMAT_V1=FROZEN`; incompatible changes require a new format version.

View file

@ -1,106 +1,252 @@
# Database # Database
## 1. Purpose ## 1. Status
SQLite is the canonical long-term data store used by the TUI.
The database is not the Android exchange format.
## 2. Initial entities
The initial data model is expected to contain:
- schema metadata;
- exercises;
- sessions;
- session exercises;
- performed sets;
- body measurements;
- optional maximum-performance records.
The exact SQL schema will be frozen before implementation.
## 3. Exercise identity
The database must preserve a stable external exercise identifier.
Conceptually:
```text ```text
exercises GATE_2_REVIEW_01=IMPLEMENTED
id internal SQLite primary key GATE_2=IN_PROGRESS
exercise_id stable Trainlog identifier DATABASE_SCHEMA_V1=DRAFT
name mutable display name
``` ```
`exercise_id` must be unique. Gate 2 review #1 establishes the persistence foundation.
## 4. Session identity The Trainlog exchange format v1 is already frozen and is not modified by this gate.
`session_id` must be unique. ## 2. Purpose
This is the primary anti-duplication barrier for imported sessions. SQLite is the canonical long-term store used by the TUI.
## 5. Foreign keys The SQLite database is an internal persistence format and is versioned independently from the Trainlog JSON exchange format.
SQLite foreign-key enforcement must be enabled explicitly for every connection: ## 3. Schema versioning
Trainlog database schema version uses SQLite:
```sql
PRAGMA user_version;
```
Initial schema:
```text
DATABASE_SCHEMA_V1=1
```
A new database starts with `user_version = 0` and is initialized atomically to version 1.
A database newer than the running binary understands is rejected.
Historical migrations are not invented. They must be explicitly implemented and tested when a schema version 2 is introduced.
## 4. Connection rules
Every Trainlog SQLite connection must enable:
```sql ```sql
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
``` ```
Tests must verify that the expected constraints are actually active. The core also configures a bounded SQLite busy timeout.
## 6. Schema versioning Foreign-key activation is verified by tests.
The database must store an explicit schema version. ## 5. Tables
Schema changes must be classified as: ### 5.1 `exercises`
- additive and compatible; Canonical exercise catalog.
- migration required;
- destructive and therefore forbidden without explicit migration logic. Fields:
```text
id internal INTEGER primary key
exercise_id stable Trainlog identity, UNIQUE
name display name
normalized_name v1 comparison form, UNIQUE
tracking_mode reps | duration
```
The database does not compute Unicode normalization in review #1.
The application/catalog layer will compute the frozen v1 normalized name in Gate 2 review #2.
SQLite owns the final uniqueness barrier.
### 5.2 `sessions`
Canonical workout session header.
Fields:
```text
id
session_id UNIQUE
started_at
ended_at nullable
notes nullable
```
Body data is stored separately so standalone body observations can use the same representation.
### 5.3 `session_exercises`
One ordered exercise within a session.
Fields include:
```text
session_row_id
exercise_row_id
position
load_mode
rest_seconds
target_sets
target_reps
target_duration_seconds
target_weight_kg
notes
```
Constraints enforce:
- one exercise identity at most once per session;
- one row at each session position;
- exactly one target metric: repetitions or duration;
- target load presence consistent with `load_mode`.
### 5.4 `performed_sets`
Ordered actual sets.
Fields:
```text
session_exercise_row_id
position
reps
duration_seconds
weight_kg
```
Exactly one of repetitions or duration is present.
Cross-table rules such as actual-set load consistency with the owning session exercise remain application/import invariants and will be tested at the import layer.
### 5.5 `body_observations`
Body history is a first-class database concept and may exist with or without a workout session.
Fields include:
```text
observation_id
observed_at
session_row_id optional and UNIQUE
body_weight_kg
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
notes
```
At least one body metric must be present.
Imported session-associated body data will create one linked observation.
Standalone TUI measurements use the same table without `session_row_id`.
## 6. UUID generation
Official Trainlog creators generate UUID version 4 identifiers.
The C core provides generated IDs for:
```text
ex_<uuid-v4>
se_<uuid-v4>
bo_<uuid-v4>
```
This is creation policy.
The frozen exchange parser remains able to accept other schema-valid opaque v1 identifiers.
## 7. Transactions ## 7. Transactions
Multi-table imports must use transactions. Multi-row operations are atomic.
A failed import must not leave a partially inserted session. Gate 2 provides explicit:
Expected behavior:
```text ```text
BEGIN BEGIN IMMEDIATE
validate
insert missing exercises
insert session
insert workout rows
insert performed sets
COMMIT COMMIT
```
On failure:
```text
ROLLBACK ROLLBACK
``` ```
primitives.
The future JSON import service must perform catalog reconciliation and all session inserts inside one transaction.
A hard conflict or validation failure leaves the database unchanged.
## 8. Units ## 8. Units
Canonical storage units: Canonical persistent units remain:
- body weight: kilograms; - weight/load: kilograms;
- load: kilograms; - body circumference: centimeters;
- body measurements: centimeters; - duration/rest: seconds.
- duration: seconds.
The UI may format values differently later, but persistent units remain explicit and stable. ## 9. Gate 2 review #1 boundary
## 9. Migration policy Review #1 intentionally does not implement:
A schema migration must: - JSON parsing;
- Unicode exercise-name normalization;
- local catalog reconciliation;
- full session insert APIs;
- body-observation CRUD;
- ncurses.
- be deterministic; Those belong to subsequent Gate 2 work.
- preserve user data;
- be testable from the previous supported version; This split keeps the first compiled C change small enough to review thoroughly.
- update the stored schema version only after success.
## 10. Validation
Normal build:
```bash
CC=clang meson setup build
meson compile -C build
meson test -C build --print-errorlogs
```
Sanitizer build:
```bash
CC=clang meson setup build-asan \
-Db_sanitize=address,undefined \
-Db_lundef=false
meson compile -C build-asan
meson test -C build-asan --print-errorlogs
```
Repository-level format validators remain mandatory:
```bash
python tools/validate_json.py
python tools/validate_import_contract.py
git diff --check
```

View file

@ -0,0 +1,146 @@
# Gate 2 Review #1 — Persistence Foundation
## Status
```text
GATE_2_REVIEW_01=IMPLEMENTED
GATE_2=IN_PROGRESS
DATABASE_SCHEMA_V1=DRAFT
TRAINLOG_FORMAT_V1=FROZEN
```
## Scope
This review introduces the first production C17 code in Trainlog.
It deliberately stops below the JSON import and ncurses layers.
## Decisions
### G2-R1-01 — Meson and strict C17
The TUI core is built with Meson using C17.
Warnings are errors.
Additional warning flags include:
```text
-Wconversion
-Wformat=2
-Wshadow
```
### G2-R1-02 — SQLite is isolated behind a C API
Application and future ncurses code do not call SQLite directly.
The first persistence API owns:
- connection lifecycle;
- schema bootstrap;
- schema version query;
- foreign-key state query;
- explicit transactions;
- exercise insertion;
- exercise count query.
### G2-R1-03 — Schema version is independent from JSON format version
Trainlog JSON v1 is frozen.
SQLite schema v1 is an internal implementation contract and may later migrate independently.
SQLite `PRAGMA user_version` is the canonical database schema number.
### G2-R1-04 — New database creation is atomic
Schema creation uses one explicit transaction.
`PRAGMA user_version = 1` is written before that transaction commits.
A schema bootstrap failure triggers best-effort rollback.
### G2-R1-05 — Newer schemas fail closed
A database with a `user_version` newer than the running binary supports is rejected.
Trainlog must not guess how to interpret newer persistent data.
### G2-R1-06 — Foreign keys are mandatory
Every connection enables:
```sql
PRAGMA foreign_keys = ON;
```
Tests verify the state.
### G2-R1-07 — UUIDv4 generation is implemented once in core
The C core uses libuuid for official:
```text
ex_
se_
bo_
```
identifier creation.
Tests verify prefix, length, version nibble, RFC variant, and non-equality of two generated IDs.
### G2-R1-08 — Normalized exercise name has a database uniqueness barrier
`normalized_name` is unique.
Review #1 intentionally accepts an already-normalized name as an API parameter.
Frozen Unicode normalization itself is implemented in review #2, so the persistence layer does not duplicate Unicode policy.
### G2-R1-09 — Body history is independent from workout history
The schema includes `body_observations`.
An observation may optionally point to exactly one session.
This allows weight and measurements to be recorded on days without a workout.
### G2-R1-10 — No ncurses yet
The TUI visual layer starts only after the persistence core is stable.
Gate 3 remains responsible for ncurses and color.
## Required validation
```bash
python tools/validate_json.py
python tools/validate_import_contract.py
CC=clang meson setup build
meson compile -C build
meson test -C build --print-errorlogs
CC=clang meson setup build-asan \
-Db_sanitize=address,undefined \
-Db_lundef=false
meson compile -C build-asan
meson test -C build-asan --print-errorlogs
git diff --check
```
## Review result
This commit is not Gate 2 PASS.
After review #1 passes, Gate 2 continues with:
```text
Unicode normalization
catalog reconciliation
JSON v1 import transaction
idempotency tests
```

View file

@ -0,0 +1,116 @@
# Weekend MVP — First Usable TUI
## Objective
Target:
```text
Monday 2026-09-07 05:00 Europe/Paris
```
The project moves temporarily in larger vertical slices.
The first safety milestone is a TUI that can be used without Android.
## Delivered user flow
```text
trainlog
|
+-- Dashboard
| +-- session count
| +-- exercise count
| +-- latest weight
| +-- compact weight sparkline
|
+-- New session
| +-- automatic start timestamp
| +-- select exercise
| +-- reps or duration
| +-- load mode
| +-- target load
| +-- planned sets
| +-- actual sets
| +-- actual reps/duration per set
| +-- actual load per set
| +-- planned rest
| +-- automatic end timestamp
|
+-- History
|
+-- Exercise catalog
| +-- add exercise
| +-- Unicode anti-duplicate normalization
|
+-- Body
+-- weight
+-- waist
+-- chest
+-- shoulders
+-- left/right arm
+-- left/right thigh
+-- left/right calf
```
## Visual contract
The TUI uses centralized color roles:
- accent;
- success;
- warning;
- error;
- muted;
- graph.
Color never replaces textual meaning.
Minimum terminal size remains:
```text
72x20
```
## Persistence
The TUI writes to:
```text
$XDG_DATA_HOME/trainlog/trainlog.db
```
or, when `XDG_DATA_HOME` is unset:
```text
~/.local/share/trainlog/trainlog.db
```
## Explicit MVP limits
The first usable TUI does not yet include:
- Android import;
- JSON export from the TUI;
- session detail editing after save;
- advanced statistics;
- 1RM calculations;
- muscle-group analysis.
These are not forgotten features; they are deliberately below the Monday usability cut.
## Next vertical slice
Immediately after this milestone:
```text
Android recorder
|
v
Trainlog JSON v1
|
v
C17 import transaction
|
v
same SQLite history
```

View file

@ -4,150 +4,106 @@
Status: PASS Status: PASS
Canonical result:
```text ```text
GATE_0=PASS GATE_0=PASS
GATE_0_REVIEW_01=PASS
```
Reviewed hardening commit:
```text
bc54d6b4ce10d098916823b6a79f72b39d9c7703
```
Gate 0 closure commit:
```text
ebd4316ed68c58598a471e567edf13455d00f92b
``` ```
## Gate 1 — Exchange format v1 freeze ## Gate 1 — Exchange format v1 freeze
Status: PASS Status: PASS
Canonical result:
```text ```text
GATE_1_REVIEW_01=PASS
GATE_1_REVIEW_02=PASS
GATE_1=PASS GATE_1=PASS
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
Reviewed commits: ## Gate 2 / Weekend MVP — Usable persistence + direct entry
Status: IN PROGRESS
Current vertical slice:
```text ```text
9d9a9223a0c46df72f5c3ab208107c0ac6698438 WEEKEND_MVP_TUI=IMPLEMENTED
dfd6717cb7978d009670f1a49029c62c9154af55 GATE_2=IN_PROGRESS
DATABASE_SCHEMA_V1=DRAFT
``` ```
Review #1 defined: Delivered in this slice:
- exercise identity and tracking mode; - SQLite persistence foundation;
- session identity and ordering; - UUIDv4 creation;
- load semantics; - frozen Unicode exercise-name normalization;
- target versus actual work; - direct exercise creation;
- rest; - direct session creation;
- body weight and measurements; - automatic start/end timestamps;
- notes; - reps/duration tracking;
- strict document validation. - none/external/assistance load modes;
- actual set recording;
Review #2 closes: - planned rest;
- standalone body measurements;
- official UUIDv4 identifier generation;
- Android/TUI catalog collision handling;
- hard tracking-mode identity conflicts;
- different-ID/same-name anti-duplicate conflicts;
- atomic catalog reconciliation.
Exit criteria satisfied before closure:
- `python tools/validate_json.py` passes;
- `python tools/validate_import_contract.py` passes;
- `git diff --check` passes;
- Android documentation aligned;
- TUI documentation aligned;
- both review commits pushed to Forgejo and GitHub;
- GitHub mirror read back and reviewed.
Gate 1 is closed.
Gate 2 is now the active gate.
## Gate 2 — TUI persistence core
Deliverables:
- Meson C17 project;
- SQLite open/create;
- schema versioning;
- UUIDv4 identity generation;
- exercise catalog;
- atomic catalog reconciliation;
- session import transaction;
- idempotent import tests.
Exit criteria:
- database tests pass;
- sanitizer validation passes.
## Gate 3 — Minimal TUI
Deliverables:
- ncursesw initialization;
- centralized color theme module;
- dashboard shell;
- exercise list;
- session history; - session history;
- direct session entry; - dashboard;
- import screen; - colored ncursesw interface;
- minimum-terminal fallback. - weight sparkline.
Exit criteria: Validation required before push:
- usable color TUI; ```bash
- monochrome fallback; python tools/validate_json.py
- UTF-8 test pass. python tools/validate_import_contract.py
CC=clang meson setup build
meson compile -C build
meson test -C build --print-errorlogs
CC=clang meson setup build-asan \
-Db_sanitize=address,undefined \
-Db_lundef=false
meson compile -C build-asan
meson test -C build-asan --print-errorlogs
git diff --check
```
Next vertical slice:
- Android v0.1 recorder;
- JSON v1 export;
- C17 JSON importer;
- catalog reconciliation;
- idempotent import.
Gate 2 remains open until import transactions and idempotency are complete.
## Gate 3 — TUI polish
The minimal colored TUI has been pulled forward for the Monday usability target.
Gate 3 later adds:
- richer navigation;
- session details;
- editing;
- more graphs;
- advanced layout polish.
## Gate 4 — Android recorder ## Gate 4 — Android recorder
Deliverables: Pulled forward immediately after Weekend MVP TUI.
- local exercise catalog;
- UUIDv4 identity generation;
- start/stop session timestamps;
- target entry;
- actual-set entry;
- rest entry;
- body data;
- JSON export.
Exit criteria:
- exported fixture validates against frozen v1;
- exported file imports successfully into the TUI.
## Gate 5 — Analytics ## Gate 5 — Analytics
Deliverables: - body-weight trends;
- measurement trends;
- body-weight trend; - exercise performance;
- measurement trend; - volume;
- exercise performance trend; - max/estimated max;
- training volume summaries; - balance analysis.
- terminal graphs.
## Gate 6 — Hardening ## Gate 6 — Hardening
Deliverables:
- broader fixture coverage;
- migrations; - migrations;
- import/export robustness;
- documentation cleanup;
- packaging; - packaging;
- broader tests;
- first tagged release. - first tagged release.

13
meson.build Normal file
View file

@ -0,0 +1,13 @@
project(
'trainlog',
'c',
version: '0.1.0',
meson_version: '>=1.3.0',
default_options: [
'c_std=c17',
'warning_level=3',
'werror=true',
],
)
subdir('tui')

View file

View file

@ -0,0 +1,43 @@
#ifndef TRAINLOG_CATALOG_H
#define TRAINLOG_CATALOG_H
/**
* @file catalog.h
* @brief Exercise-name normalization and canonical catalog creation.
*/
#include <stddef.h>
#include "trainlog/database.h"
#include "trainlog/status.h"
/**
* @brief Normalize an exercise display name according to frozen Trainlog v1.
*
* Rules:
* - Unicode NFC composition;
* - trim leading/trailing Unicode whitespace;
* - collapse whitespace runs to one ASCII space;
* - Unicode case folding.
*
* @param input UTF-8 display name.
* @param output Caller-owned UTF-8 buffer.
* @param output_size Size of @p output.
*/
TrainlogStatus trainlog_catalog_normalize_name(
const char *input,
char *output,
size_t output_size
);
/**
* @brief Create one new canonical exercise with a generated UUIDv4 identity.
*/
TrainlogStatus trainlog_catalog_create_exercise(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogExercise *output_exercise
);
#endif

View file

@ -0,0 +1,94 @@
#ifndef TRAINLOG_DATABASE_H
#define TRAINLOG_DATABASE_H
/**
* @file database.h
* @brief SQLite persistence API for the Trainlog TUI core.
*/
#include <stddef.h>
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 1
typedef struct TrainlogDatabase TrainlogDatabase;
TrainlogStatus trainlog_database_open(
const char *path,
TrainlogDatabase **output_database
);
void trainlog_database_close(TrainlogDatabase *database);
TrainlogStatus trainlog_database_schema_version(
TrainlogDatabase *database,
int *output_version
);
TrainlogStatus trainlog_database_foreign_keys_enabled(
TrainlogDatabase *database,
int *output_enabled
);
TrainlogStatus trainlog_database_begin(TrainlogDatabase *database);
TrainlogStatus trainlog_database_commit(TrainlogDatabase *database);
TrainlogStatus trainlog_database_rollback(TrainlogDatabase *database);
/**
* @brief Insert one already-normalized canonical exercise row.
*
* Unicode normalization belongs to catalog.c. This lower-level API owns the
* final SQLite uniqueness barrier.
*/
TrainlogStatus trainlog_database_insert_exercise(
TrainlogDatabase *database,
const char *exercise_id,
const char *name,
const char *normalized_name,
TrainlogTrackingMode tracking_mode
);
TrainlogStatus trainlog_database_exercise_count(
TrainlogDatabase *database,
size_t *output_count
);
TrainlogStatus trainlog_database_list_exercises(
TrainlogDatabase *database,
TrainlogExercise *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_insert_session(
TrainlogDatabase *database,
const TrainlogSessionInput *session
);
TrainlogStatus trainlog_database_session_count(
TrainlogDatabase *database,
size_t *output_count
);
TrainlogStatus trainlog_database_list_sessions(
TrainlogDatabase *database,
TrainlogSessionSummary *output,
size_t capacity,
size_t *output_count
);
TrainlogStatus trainlog_database_insert_body_observation(
TrainlogDatabase *database,
const TrainlogBodyObservationInput *observation
);
TrainlogStatus trainlog_database_list_weight_points(
TrainlogDatabase *database,
TrainlogWeightPoint *output,
size_t capacity,
size_t *output_count
);
#endif

51
tui/include/trainlog/id.h Normal file
View file

@ -0,0 +1,51 @@
#ifndef TRAINLOG_ID_H
#define TRAINLOG_ID_H
/**
* @file id.h
* @brief Collision-resistant Trainlog identifier generation.
*/
#include <stddef.h>
#include "trainlog/status.h"
/** UUID text length without a terminating NUL byte. */
#define TRAINLOG_UUID_TEXT_LENGTH 36U
/**
* Longest official v1 generated identifier:
* two-character type prefix + '_' + UUID text + terminating NUL.
*/
#define TRAINLOG_GENERATED_ID_CAPACITY \
(2U + 1U + TRAINLOG_UUID_TEXT_LENGTH + 1U)
/**
* @brief Generate an official Trainlog v1 identifier using UUID version 4.
*
* The frozen v1 contract requires official creators to generate identifiers
* in the form `<prefix>_<uuid-v4>`. The function accepts only the currently
* reserved two-character prefixes:
*
* - `ex` for exercises;
* - `se` for sessions;
* - `bo` for body observations.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.
*
* @param prefix Two-character Trainlog object prefix.
* @param output Caller-owned output buffer.
* @param output_size Size of @p output in bytes.
*
* @return TRAINLOG_STATUS_OK on success.
* @return TRAINLOG_STATUS_INVALID_ARGUMENT for invalid pointers, prefix, or a
* buffer smaller than TRAINLOG_GENERATED_ID_CAPACITY.
*/
TrainlogStatus trainlog_id_generate(
const char *prefix,
char *output,
size_t output_size
);
#endif

View file

@ -0,0 +1,115 @@
#ifndef TRAINLOG_MODEL_H
#define TRAINLOG_MODEL_H
/**
* @file model.h
* @brief Bounded data structures shared by Trainlog TUI core layers.
*/
#include <stdbool.h>
#include <stddef.h>
#define TRAINLOG_ID_MAX 128U
#define TRAINLOG_NAME_MAX 200U
#define TRAINLOG_TIMESTAMP_MAX 40U
#define TRAINLOG_NOTE_MAX 4000U
typedef enum TrainlogTrackingMode {
TRAINLOG_TRACKING_REPS = 0,
TRAINLOG_TRACKING_DURATION
} TrainlogTrackingMode;
typedef enum TrainlogLoadMode {
TRAINLOG_LOAD_NONE = 0,
TRAINLOG_LOAD_EXTERNAL,
TRAINLOG_LOAD_ASSISTANCE
} TrainlogLoadMode;
typedef struct TrainlogExercise {
char exercise_id[TRAINLOG_ID_MAX + 1U];
char name[TRAINLOG_NAME_MAX + 1U];
TrainlogTrackingMode tracking_mode;
} TrainlogExercise;
typedef struct TrainlogSetInput {
int reps;
int duration_seconds;
bool has_weight;
double weight_kg;
} TrainlogSetInput;
typedef struct TrainlogSessionExerciseInput {
char exercise_id[TRAINLOG_ID_MAX + 1U];
TrainlogLoadMode load_mode;
int rest_seconds;
int target_sets;
int target_reps;
int target_duration_seconds;
bool target_has_weight;
double target_weight_kg;
const char *notes;
const TrainlogSetInput *sets;
size_t set_count;
} TrainlogSessionExerciseInput;
typedef struct TrainlogSessionInput {
char session_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
const char *notes;
const TrainlogSessionExerciseInput *exercises;
size_t exercise_count;
} TrainlogSessionInput;
typedef struct TrainlogSessionSummary {
char session_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
size_t exercise_count;
} TrainlogSessionSummary;
/**
* Missing body values are represented by has_* flags rather than sentinel
* numbers so zero and NaN never acquire accidental persistence semantics.
*/
typedef struct TrainlogBodyObservationInput {
char observation_id[TRAINLOG_ID_MAX + 1U];
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
const char *session_id;
bool has_body_weight;
double body_weight_kg;
bool has_neck;
double neck_cm;
bool has_shoulders;
double shoulders_cm;
bool has_chest;
double chest_cm;
bool has_waist;
double waist_cm;
bool has_hips;
double hips_cm;
bool has_left_arm;
double left_arm_cm;
bool has_right_arm;
double right_arm_cm;
bool has_left_forearm;
double left_forearm_cm;
bool has_right_forearm;
double right_forearm_cm;
bool has_left_thigh;
double left_thigh_cm;
bool has_right_thigh;
double right_thigh_cm;
bool has_left_calf;
double left_calf_cm;
bool has_right_calf;
double right_calf_cm;
const char *notes;
} TrainlogBodyObservationInput;
typedef struct TrainlogWeightPoint {
char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U];
double body_weight_kg;
} TrainlogWeightPoint;
#endif

View file

@ -0,0 +1,26 @@
#ifndef TRAINLOG_STATUS_H
#define TRAINLOG_STATUS_H
/**
* @file status.h
* @brief Common status values returned by Trainlog core APIs.
*/
/**
* @brief Result codes used by non-trivial Trainlog core operations.
*
* Callers must not infer SQLite error numbers from this enum. The persistence
* layer deliberately translates backend-specific failures into the small,
* stable status surface needed by the application layer.
*/
typedef enum TrainlogStatus {
TRAINLOG_STATUS_OK = 0,
TRAINLOG_STATUS_INVALID_ARGUMENT,
TRAINLOG_STATUS_SYSTEM_ERROR,
TRAINLOG_STATUS_DATABASE_ERROR,
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED,
TRAINLOG_STATUS_CONFLICT,
TRAINLOG_STATUS_NOT_FOUND
} TrainlogStatus;
#endif

View file

@ -0,0 +1,24 @@
#ifndef TRAINLOG_THEME_H
#define TRAINLOG_THEME_H
#include <curses.h>
/**
* @file theme.h
* @brief Centralized ncurses color roles for the Trainlog TUI.
*/
typedef enum TrainlogColorRole {
TRAINLOG_COLOR_DEFAULT = 0,
TRAINLOG_COLOR_ACCENT = 1,
TRAINLOG_COLOR_SUCCESS = 2,
TRAINLOG_COLOR_WARNING = 3,
TRAINLOG_COLOR_ERROR = 4,
TRAINLOG_COLOR_MUTED = 5,
TRAINLOG_COLOR_GRAPH = 6
} TrainlogColorRole;
void trainlog_theme_initialize(void);
attr_t trainlog_theme_attribute(TrainlogColorRole role);
#endif

View file

@ -0,0 +1,23 @@
#ifndef TRAINLOG_TIMEUTIL_H
#define TRAINLOG_TIMEUTIL_H
/**
* @file timeutil.h
* @brief RFC3339 local timestamp helpers.
*/
#include <stddef.h>
#include "trainlog/status.h"
/**
* @brief Write the current local instant with an explicit numeric UTC offset.
*
* Example: `2026-09-05T19:42:01+02:00`.
*/
TrainlogStatus trainlog_time_now_rfc3339(
char *output,
size_t output_size
);
#endif

View file

@ -0,0 +1,13 @@
#ifndef TRAINLOG_TUI_H
#define TRAINLOG_TUI_H
/**
* @file tui.h
* @brief Interactive ncurses entry point.
*/
#include "trainlog/database.h"
int trainlog_tui_run(TrainlogDatabase *database);
#endif

97
tui/meson.build Normal file
View file

@ -0,0 +1,97 @@
cc = meson.get_compiler('c')
sqlite3_dep = dependency('sqlite3', required: true)
uuid_dep = dependency('uuid', required: true)
ncursesw_dep = dependency('ncursesw', required: false)
if not ncursesw_dep.found()
ncursesw_dep = cc.find_library('ncursesw', required: true)
endif
utf8proc_dep = dependency('libutf8proc', required: false)
if not utf8proc_dep.found()
utf8proc_dep = dependency('utf8proc', required: true)
endif
m_dep = cc.find_library('m', required: true)
trainlog_include = include_directories('include')
strict_c_args = [
'-D_POSIX_C_SOURCE=200809L',
'-Wconversion',
'-Wformat=2',
'-Wshadow',
]
trainlog_core_sources = files(
'src/catalog.c',
'src/database.c',
'src/id.c',
'src/timeutil.c',
)
trainlog_core = static_library(
'trainlog_core',
trainlog_core_sources,
include_directories: trainlog_include,
dependencies: [
sqlite3_dep,
uuid_dep,
utf8proc_dep,
m_dep,
],
c_args: strict_c_args,
)
trainlog_core_dep = declare_dependency(
link_with: trainlog_core,
include_directories: trainlog_include,
dependencies: [
sqlite3_dep,
uuid_dep,
utf8proc_dep,
m_dep,
],
)
trainlog_tui_sources = files(
'src/main.c',
'src/theme.c',
'src/tui.c',
)
trainlog_exe = executable(
'trainlog',
trainlog_tui_sources,
dependencies: [
trainlog_core_dep,
ncursesw_dep,
],
c_args: strict_c_args,
install: true,
)
test_database = executable(
'test_database',
'tests/test_database.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test_catalog = executable(
'test_catalog',
'tests/test_catalog.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test(
'database',
test_database,
)
test(
'catalog',
test_catalog,
)

197
tui/src/catalog.c Normal file
View file

@ -0,0 +1,197 @@
/**
* @file catalog.c
* @brief Frozen Trainlog v1 exercise-name normalization.
*/
#include "trainlog/catalog.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <utf8proc.h>
#include "trainlog/id.h"
static bool codepoint_is_whitespace(utf8proc_int32_t codepoint)
{
utf8proc_category_t category = utf8proc_category(codepoint);
if (codepoint == '\t' ||
codepoint == '\n' ||
codepoint == '\v' ||
codepoint == '\f' ||
codepoint == '\r') {
return true;
}
return category == UTF8PROC_CATEGORY_ZS ||
category == UTF8PROC_CATEGORY_ZL ||
category == UTF8PROC_CATEGORY_ZP;
}
TrainlogStatus trainlog_catalog_normalize_name(
const char *input,
char *output,
size_t output_size
)
{
utf8proc_uint8_t *mapped = NULL;
utf8proc_ssize_t mapped_length;
utf8proc_ssize_t offset = 0;
size_t output_used = 0U;
bool pending_space = false;
bool wrote_content = false;
if (input == NULL ||
output == NULL ||
output_size == 0U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output[0] = '\0';
mapped_length = utf8proc_map(
(const utf8proc_uint8_t *)input,
0,
&mapped,
UTF8PROC_STABLE |
UTF8PROC_COMPOSE |
UTF8PROC_CASEFOLD |
UTF8PROC_NULLTERM
);
if (mapped_length < 0 || mapped == NULL) {
free(mapped);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
while (offset < mapped_length) {
utf8proc_int32_t codepoint = 0;
utf8proc_ssize_t consumed;
utf8proc_uint8_t encoded[4];
utf8proc_ssize_t encoded_length;
consumed = utf8proc_iterate(
mapped + offset,
mapped_length - offset,
&codepoint
);
if (consumed <= 0) {
free(mapped);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
offset += consumed;
if (codepoint_is_whitespace(codepoint)) {
if (wrote_content) {
pending_space = true;
}
continue;
}
if (pending_space) {
if (output_used + 1U >= output_size) {
free(mapped);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output[output_used++] = ' ';
pending_space = false;
}
encoded_length = utf8proc_encode_char(codepoint, encoded);
if (encoded_length <= 0) {
free(mapped);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (output_used + (size_t)encoded_length >= output_size) {
free(mapped);
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)memcpy(
output + output_used,
encoded,
(size_t)encoded_length
);
output_used += (size_t)encoded_length;
wrote_content = true;
}
free(mapped);
if (!wrote_content) {
output[0] = '\0';
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
output[output_used] = '\0';
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_catalog_create_exercise(
TrainlogDatabase *database,
const char *name,
TrainlogTrackingMode tracking_mode,
TrainlogExercise *output_exercise
)
{
char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U];
char exercise_id[TRAINLOG_GENERATED_ID_CAPACITY];
TrainlogStatus status;
if (database == NULL ||
name == NULL ||
output_exercise == NULL ||
strlen(name) > TRAINLOG_NAME_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = trainlog_catalog_normalize_name(
name,
normalized,
sizeof(normalized)
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = trainlog_id_generate(
"ex",
exercise_id,
sizeof(exercise_id)
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
status = trainlog_database_insert_exercise(
database,
exercise_id,
name,
normalized,
tracking_mode
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
(void)memset(output_exercise, 0, sizeof(*output_exercise));
(void)snprintf(
output_exercise->exercise_id,
sizeof(output_exercise->exercise_id),
"%s",
exercise_id
);
(void)snprintf(
output_exercise->name,
sizeof(output_exercise->name),
"%s",
name
);
output_exercise->tracking_mode = tracking_mode;
return TRAINLOG_STATUS_OK;
}

1205
tui/src/database.c Normal file

File diff suppressed because it is too large Load diff

66
tui/src/id.c Normal file
View file

@ -0,0 +1,66 @@
/**
* @file id.c
* @brief Trainlog UUIDv4 identifier generation.
*/
#include "trainlog/id.h"
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <uuid/uuid.h>
static bool prefix_is_supported(const char *prefix)
{
/*
* Keep official creator prefixes deliberately small and explicit.
* The exchange parser remains more permissive because imported v1 IDs are
* opaque values governed by the frozen wire-schema syntax.
*/
return strcmp(prefix, "ex") == 0 ||
strcmp(prefix, "se") == 0 ||
strcmp(prefix, "bo") == 0;
}
TrainlogStatus trainlog_id_generate(
const char *prefix,
char *output,
size_t output_size
)
{
uuid_t value;
char uuid_text[TRAINLOG_UUID_TEXT_LENGTH + 1U];
int written;
if (prefix == NULL || output == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
if (!prefix_is_supported(prefix) ||
output_size < TRAINLOG_GENERATED_ID_CAPACITY) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
/*
* libuuid's uuid_generate_random() creates a random RFC 4122 UUID with the
* version and variant bits set appropriately for UUID version 4.
*/
uuid_generate_random(value);
uuid_unparse_lower(value, uuid_text);
written = snprintf(
output,
output_size,
"%s_%s",
prefix,
uuid_text
);
if (written < 0 ||
(size_t)written >= output_size) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}

108
tui/src/main.c Normal file
View file

@ -0,0 +1,108 @@
/**
* @file main.c
* @brief Trainlog TUI process entry point.
*/
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include "trainlog/database.h"
#include "trainlog/tui.h"
static int ensure_directory(const char *path)
{
if (mkdir(path, 0700) == 0 || errno == EEXIST) {
return 0;
}
return -1;
}
static int build_database_path(char *output, size_t output_size)
{
const char *xdg = getenv("XDG_DATA_HOME");
const char *home = getenv("HOME");
char base[PATH_MAX];
char trainlog_dir[PATH_MAX];
int written;
if (xdg != NULL && xdg[0] != '\0') {
written = snprintf(base, sizeof(base), "%s", xdg);
} else if (home != NULL && home[0] != '\0') {
written = snprintf(base, sizeof(base), "%s/.local/share", home);
} else {
return -1;
}
if (written < 0 || (size_t)written >= sizeof(base)) {
return -1;
}
/*
* Creating ~/.local and ~/.local/share recursively would normally require
* a generic mkdir -p helper. On Arch these parents already exist for a
* desktop user; XDG_DATA_HOME custom paths are likewise expected to have
* their parent created by the user.
*/
if (ensure_directory(base) != 0 && errno != EEXIST) {
/* Continue only when the base already exists. */
struct stat info;
if (stat(base, &info) != 0 || !S_ISDIR(info.st_mode)) {
return -1;
}
}
written = snprintf(
trainlog_dir,
sizeof(trainlog_dir),
"%s/trainlog",
base
);
if (written < 0 || (size_t)written >= sizeof(trainlog_dir)) {
return -1;
}
if (ensure_directory(trainlog_dir) != 0) {
return -1;
}
written = snprintf(
output,
output_size,
"%s/trainlog.db",
trainlog_dir
);
return written >= 0 && (size_t)written < output_size ? 0 : -1;
}
int main(void)
{
char database_path[PATH_MAX];
TrainlogDatabase *database = NULL;
TrainlogStatus status;
int result;
if (build_database_path(database_path, sizeof(database_path)) != 0) {
(void)fprintf(stderr, "trainlog: unable to create data directory\n");
return 1;
}
status = trainlog_database_open(database_path, &database);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(
stderr,
"trainlog: unable to open database (%d)\n",
(int)status
);
return 1;
}
result = trainlog_tui_run(database);
trainlog_database_close(database);
return result;
}

34
tui/src/theme.c Normal file
View file

@ -0,0 +1,34 @@
/**
* @file theme.c
* @brief Centralized Trainlog ncurses colors.
*/
#include "trainlog/theme.h"
#include <curses.h>
void trainlog_theme_initialize(void)
{
if (!has_colors()) {
return;
}
start_color();
use_default_colors();
init_pair(TRAINLOG_COLOR_ACCENT, COLOR_CYAN, -1);
init_pair(TRAINLOG_COLOR_SUCCESS, COLOR_GREEN, -1);
init_pair(TRAINLOG_COLOR_WARNING, COLOR_YELLOW, -1);
init_pair(TRAINLOG_COLOR_ERROR, COLOR_RED, -1);
init_pair(TRAINLOG_COLOR_MUTED, COLOR_BLUE, -1);
init_pair(TRAINLOG_COLOR_GRAPH, COLOR_MAGENTA, -1);
}
attr_t trainlog_theme_attribute(TrainlogColorRole role)
{
if (!has_colors() || role == TRAINLOG_COLOR_DEFAULT) {
return A_NORMAL;
}
return COLOR_PAIR((short)role);
}

76
tui/src/timeutil.c Normal file
View file

@ -0,0 +1,76 @@
/**
* @file timeutil.c
* @brief Local RFC3339 timestamp generation.
*/
#include "trainlog/timeutil.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
TrainlogStatus trainlog_time_now_rfc3339(
char *output,
size_t output_size
)
{
time_t now;
struct tm local;
char date_part[32];
char offset_part[8];
char offset_with_colon[7];
int written;
if (output == NULL || output_size < 26U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
now = time(NULL);
if (now == (time_t)-1) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (localtime_r(&now, &local) == NULL) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (strftime(
date_part,
sizeof(date_part),
"%Y-%m-%dT%H:%M:%S",
&local
) == 0U) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (strftime(
offset_part,
sizeof(offset_part),
"%z",
&local
) != 5U) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
offset_with_colon[0] = offset_part[0];
offset_with_colon[1] = offset_part[1];
offset_with_colon[2] = offset_part[2];
offset_with_colon[3] = ':';
offset_with_colon[4] = offset_part[3];
offset_with_colon[5] = offset_part[4];
offset_with_colon[6] = '\0';
written = snprintf(
output,
output_size,
"%s%s",
date_part,
offset_with_colon
);
if (written < 0 || (size_t)written >= output_size) {
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
return TRAINLOG_STATUS_OK;
}

989
tui/src/tui.c Normal file
View file

@ -0,0 +1,989 @@
/**
* @file tui.c
* @brief First usable Trainlog ncurses interface.
*/
#include "trainlog/tui.h"
#include <ctype.h>
#include <locale.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <curses.h>
#include "trainlog/catalog.h"
#include "trainlog/id.h"
#include "trainlog/theme.h"
#include "trainlog/timeutil.h"
#define MAX_EXERCISES 128U
#define MAX_SESSION_EXERCISES 32U
#define MAX_SETS_PER_EXERCISE 64U
#define MAX_SESSIONS 128U
#define MAX_WEIGHT_POINTS 256U
static void wait_key(void)
{
attron(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED));
mvprintw(LINES - 2, 2, "Appuyez sur une touche pour continuer...");
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_MUTED));
refresh();
(void)getch();
}
static void title(const char *text)
{
attron(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT));
mvprintw(1, 2, "%s", text);
attroff(A_BOLD | trainlog_theme_attribute(TRAINLOG_COLOR_ACCENT));
}
static void status_line(const char *text, TrainlogColorRole role)
{
attron(trainlog_theme_attribute(role));
mvprintw(LINES - 3, 2, "%-*s", COLS - 4, text);
attroff(trainlog_theme_attribute(role));
}
static bool prompt_text(
int row,
const char *label,
char *output,
size_t output_size,
bool allow_empty
)
{
int rc;
mvprintw(row, 2, "%s", label);
clrtoeol();
echo();
curs_set(1);
rc = getnstr(output, (int)(output_size - 1U));
noecho();
curs_set(0);
if (rc == ERR) {
return false;
}
if (!allow_empty && output[0] == '\0') {
return false;
}
return true;
}
static bool parse_int(const char *text, int minimum, int maximum, int *output)
{
char *end = NULL;
long value;
if (text == NULL || output == NULL || text[0] == '\0') {
return false;
}
value = strtol(text, &end, 10);
if (end == text || *end != '\0' ||
value < (long)minimum || value > (long)maximum) {
return false;
}
*output = (int)value;
return true;
}
static bool parse_double_positive(const char *text, double *output)
{
char *end = NULL;
double value;
if (text == NULL || output == NULL || text[0] == '\0') {
return false;
}
value = strtod(text, &end);
if (end == text || *end != '\0' || value <= 0.0) {
return false;
}
*output = value;
return true;
}
static bool prompt_int_value(
int row,
const char *label,
int minimum,
int maximum,
int default_value,
int *output
)
{
char buffer[64];
for (;;) {
char decorated[128];
(void)snprintf(
decorated,
sizeof(decorated),
"%s [%d]: ",
label,
default_value
);
if (!prompt_text(row, decorated, buffer, sizeof(buffer), true)) {
return false;
}
if (buffer[0] == '\0') {
*output = default_value;
return true;
}
if (parse_int(buffer, minimum, maximum, output)) {
return true;
}
status_line("Valeur entière invalide.", TRAINLOG_COLOR_ERROR);
refresh();
}
}
static bool prompt_optional_double(
int row,
const char *label,
bool *present,
double *output
)
{
char buffer[64];
for (;;) {
if (!prompt_text(row, label, buffer, sizeof(buffer), true)) {
return false;
}
if (buffer[0] == '\0') {
*present = false;
*output = 0.0;
return true;
}
if (parse_double_positive(buffer, output)) {
*present = true;
return true;
}
status_line("Nombre positif invalide.", TRAINLOG_COLOR_ERROR);
refresh();
}
}
static void draw_weight_sparkline(
int row,
const TrainlogWeightPoint *points,
size_t count
)
{
static const char *const blocks[] = {
"", "", "", "", "", "", "", ""
};
double minimum;
double maximum;
size_t start;
size_t index;
int column = 2;
if (count == 0U) {
mvprintw(row, 2, "Poids : aucune donnée");
return;
}
start = count > 40U ? count - 40U : 0U;
minimum = points[start].body_weight_kg;
maximum = points[start].body_weight_kg;
for (index = start + 1U; index < count; ++index) {
if (points[index].body_weight_kg < minimum) {
minimum = points[index].body_weight_kg;
}
if (points[index].body_weight_kg > maximum) {
maximum = points[index].body_weight_kg;
}
}
mvprintw(
row,
2,
"Poids %.1f kg min %.1f max %.1f ",
points[count - 1U].body_weight_kg,
minimum,
maximum
);
column = getcurx(stdscr);
attron(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH));
for (index = start; index < count && column < COLS - 2; ++index) {
size_t bucket = 3U;
if (maximum > minimum) {
double ratio =
(points[index].body_weight_kg - minimum) /
(maximum - minimum);
double scaled = ratio * 7.0;
bucket = (size_t)(scaled + 0.5);
if (bucket > 7U) {
bucket = 7U;
}
}
mvprintw(row, column, "%s", blocks[bucket]);
++column;
}
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_GRAPH));
}
static void screen_dashboard(TrainlogDatabase *database)
{
size_t session_count = 0U;
size_t exercise_count = 0U;
TrainlogWeightPoint points[MAX_WEIGHT_POINTS];
size_t weight_count = 0U;
erase();
title("TRAINLOG — Dashboard");
(void)trainlog_database_session_count(database, &session_count);
(void)trainlog_database_exercise_count(database, &exercise_count);
(void)trainlog_database_list_weight_points(
database,
points,
MAX_WEIGHT_POINTS,
&weight_count
);
mvprintw(4, 2, "Séances enregistrées : %zu", session_count);
mvprintw(5, 2, "Exercices connus : %zu", exercise_count);
draw_weight_sparkline(7, points, weight_count);
mvprintw(10, 2, "1 Nouvelle séance");
mvprintw(11, 2, "2 Historique");
mvprintw(12, 2, "3 Exercices");
mvprintw(13, 2, "4 Corps / mensurations");
mvprintw(14, 2, "q Quitter");
refresh();
}
static void screen_exercises(TrainlogDatabase *database)
{
TrainlogExercise exercises[MAX_EXERCISES];
size_t count = 0U;
size_t index;
int key;
for (;;) {
erase();
title("TRAINLOG — Exercices");
if (trainlog_database_list_exercises(
database,
exercises,
MAX_EXERCISES,
&count
) != TRAINLOG_STATUS_OK) {
status_line("Erreur base de données.", TRAINLOG_COLOR_ERROR);
wait_key();
return;
}
if (count == 0U) {
mvprintw(4, 2, "Aucun exercice.");
} else {
for (index = 0U;
index < count && 4 + (int)index < LINES - 5;
++index) {
mvprintw(
4 + (int)index,
2,
"%3zu %-35s [%s]",
index + 1U,
exercises[index].name,
exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS
? "reps"
: "durée"
);
}
}
mvprintw(LINES - 3, 2, "a Ajouter b Retour");
refresh();
key = getch();
if (key == 'b' || key == 27) {
return;
}
if (key == 'a') {
char name[TRAINLOG_NAME_MAX + 1U];
int mode = 1;
TrainlogExercise created;
TrainlogStatus status;
erase();
title("Nouvel exercice");
if (!prompt_text(
4,
"Nom : ",
name,
sizeof(name),
false
)) {
continue;
}
if (!prompt_int_value(
5,
"Mode 1=reps 2=durée",
1,
2,
1,
&mode
)) {
continue;
}
status = trainlog_catalog_create_exercise(
database,
name,
mode == 1
? TRAINLOG_TRACKING_REPS
: TRAINLOG_TRACKING_DURATION,
&created
);
if (status == TRAINLOG_STATUS_OK) {
status_line("Exercice ajouté.", TRAINLOG_COLOR_SUCCESS);
} else if (status == TRAINLOG_STATUS_CONFLICT) {
status_line(
"Doublon détecté : nom ou identité déjà présent.",
TRAINLOG_COLOR_WARNING
);
} else {
status_line("Impossible d'ajouter l'exercice.", TRAINLOG_COLOR_ERROR);
}
wait_key();
}
}
}
static bool choose_exercise(
TrainlogDatabase *database,
TrainlogExercise *output
)
{
TrainlogExercise exercises[MAX_EXERCISES];
size_t count = 0U;
size_t index;
int selected = 0;
if (trainlog_database_list_exercises(
database,
exercises,
MAX_EXERCISES,
&count
) != TRAINLOG_STATUS_OK ||
count == 0U) {
return false;
}
erase();
title("Choisir un exercice");
for (index = 0U;
index < count && 4 + (int)index < LINES - 5;
++index) {
mvprintw(
4 + (int)index,
2,
"%3zu %s",
index + 1U,
exercises[index].name
);
}
if (!prompt_int_value(
LINES - 4,
"Numéro",
1,
(int)count,
1,
&selected
)) {
return false;
}
*output = exercises[(size_t)selected - 1U];
return true;
}
static bool build_session_exercise(
TrainlogDatabase *database,
TrainlogSessionExerciseInput *output,
TrainlogSetInput *set_storage,
size_t set_capacity
)
{
TrainlogExercise exercise;
int load_mode = 1;
int target_sets = 3;
int target_metric = 10;
int rest_seconds = 60;
int actual_sets;
size_t set_index;
bool target_has_weight = false;
double target_weight = 0.0;
if (!choose_exercise(database, &exercise)) {
return false;
}
erase();
title(exercise.name);
if (!prompt_int_value(
4,
"Charge 1=aucune 2=externe 3=assistance",
1,
3,
1,
&load_mode
)) {
return false;
}
if (load_mode != 1) {
if (!prompt_optional_double(
5,
"Charge cible kg : ",
&target_has_weight,
&target_weight
) || !target_has_weight) {
return false;
}
}
if (!prompt_int_value(
6,
"Nombre de séries prévues",
1,
(int)set_capacity,
3,
&target_sets
)) {
return false;
}
if (!prompt_int_value(
7,
exercise.tracking_mode == TRAINLOG_TRACKING_REPS
? "Répétitions cibles"
: "Durée cible (secondes)",
1,
10000,
exercise.tracking_mode == TRAINLOG_TRACKING_REPS ? 10 : 45,
&target_metric
)) {
return false;
}
if (!prompt_int_value(
8,
"Repos prévu (secondes)",
0,
86400,
60,
&rest_seconds
)) {
return false;
}
if (!prompt_int_value(
9,
"Séries réellement faites",
0,
(int)set_capacity,
target_sets,
&actual_sets
)) {
return false;
}
(void)memset(output, 0, sizeof(*output));
(void)snprintf(
output->exercise_id,
sizeof(output->exercise_id),
"%s",
exercise.exercise_id
);
output->load_mode =
load_mode == 1
? TRAINLOG_LOAD_NONE
: (load_mode == 2
? TRAINLOG_LOAD_EXTERNAL
: TRAINLOG_LOAD_ASSISTANCE);
output->rest_seconds = rest_seconds;
output->target_sets = target_sets;
output->target_reps =
exercise.tracking_mode == TRAINLOG_TRACKING_REPS
? target_metric
: 0;
output->target_duration_seconds =
exercise.tracking_mode == TRAINLOG_TRACKING_DURATION
? target_metric
: 0;
output->target_has_weight = target_has_weight;
output->target_weight_kg = target_weight;
output->sets = set_storage;
output->set_count = (size_t)actual_sets;
for (set_index = 0U; set_index < output->set_count; ++set_index) {
int actual_metric = target_metric;
char label[128];
(void)memset(&set_storage[set_index], 0, sizeof(set_storage[set_index]));
erase();
title(exercise.name);
mvprintw(
3,
2,
"Série %zu / %zu",
set_index + 1U,
output->set_count
);
(void)snprintf(
label,
sizeof(label),
"%s réalisé",
exercise.tracking_mode == TRAINLOG_TRACKING_REPS
? "Répétitions"
: "Durée (secondes)"
);
if (!prompt_int_value(
5,
label,
exercise.tracking_mode == TRAINLOG_TRACKING_REPS ? 0 : 1,
10000,
target_metric,
&actual_metric
)) {
return false;
}
if (exercise.tracking_mode == TRAINLOG_TRACKING_REPS) {
set_storage[set_index].reps = actual_metric;
} else {
set_storage[set_index].duration_seconds = actual_metric;
}
if (target_has_weight) {
bool has_weight = false;
double weight = target_weight;
char prompt[128];
(void)snprintf(
prompt,
sizeof(prompt),
"Charge kg [%.1f, vide = cible] : ",
target_weight
);
{
char buffer[64];
if (!prompt_text(6, prompt, buffer, sizeof(buffer), true)) {
return false;
}
if (buffer[0] == '\0') {
has_weight = true;
weight = target_weight;
} else if (parse_double_positive(buffer, &weight)) {
has_weight = true;
} else {
status_line("Charge invalide.", TRAINLOG_COLOR_ERROR);
wait_key();
return false;
}
}
set_storage[set_index].has_weight = has_weight;
set_storage[set_index].weight_kg = weight;
}
}
return true;
}
static void screen_new_session(TrainlogDatabase *database)
{
TrainlogSessionExerciseInput exercise_inputs[MAX_SESSION_EXERCISES];
TrainlogSetInput set_storage[MAX_SESSION_EXERCISES][MAX_SETS_PER_EXERCISE];
TrainlogSessionInput session;
char session_id[TRAINLOG_GENERATED_ID_CAPACITY];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
size_t exercise_count = 0U;
int another = 1;
TrainlogStatus status;
if (trainlog_database_exercise_count(
database,
&exercise_count
) != TRAINLOG_STATUS_OK) {
return;
}
if (exercise_count == 0U) {
erase();
title("Nouvelle séance");
status_line(
"Ajoutez d'abord au moins un exercice.",
TRAINLOG_COLOR_WARNING
);
wait_key();
return;
}
exercise_count = 0U;
if (trainlog_id_generate(
"se",
session_id,
sizeof(session_id)
) != TRAINLOG_STATUS_OK ||
trainlog_time_now_rfc3339(
started_at,
sizeof(started_at)
) != TRAINLOG_STATUS_OK) {
return;
}
while (another == 1 && exercise_count < MAX_SESSION_EXERCISES) {
if (!build_session_exercise(
database,
&exercise_inputs[exercise_count],
set_storage[exercise_count],
MAX_SETS_PER_EXERCISE
)) {
break;
}
++exercise_count;
erase();
title("Séance en cours");
mvprintw(4, 2, "%zu exercice(s) enregistré(s).", exercise_count);
if (!prompt_int_value(
6,
"Ajouter un autre exercice ? 1=oui 0=non",
0,
1,
0,
&another
)) {
another = 0;
}
}
if (trainlog_time_now_rfc3339(
ended_at,
sizeof(ended_at)
) != TRAINLOG_STATUS_OK) {
return;
}
(void)memset(&session, 0, sizeof(session));
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
session_id
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
started_at
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
ended_at
);
session.exercises = exercise_inputs;
session.exercise_count = exercise_count;
status = trainlog_database_insert_session(database, &session);
erase();
title("Fin de séance");
if (status == TRAINLOG_STATUS_OK) {
attron(trainlog_theme_attribute(TRAINLOG_COLOR_SUCCESS));
mvprintw(4, 2, "✓ Séance enregistrée.");
attroff(trainlog_theme_attribute(TRAINLOG_COLOR_SUCCESS));
mvprintw(6, 2, "Début : %s", started_at);
mvprintw(7, 2, "Fin : %s", ended_at);
mvprintw(8, 2, "Exercices : %zu", exercise_count);
} else {
status_line(
"Échec lors de l'enregistrement de la séance.",
TRAINLOG_COLOR_ERROR
);
}
wait_key();
}
static void screen_history(TrainlogDatabase *database)
{
TrainlogSessionSummary sessions[MAX_SESSIONS];
size_t count = 0U;
size_t index;
erase();
title("TRAINLOG — Historique");
if (trainlog_database_list_sessions(
database,
sessions,
MAX_SESSIONS,
&count
) != TRAINLOG_STATUS_OK) {
status_line("Erreur base de données.", TRAINLOG_COLOR_ERROR);
wait_key();
return;
}
if (count == 0U) {
mvprintw(4, 2, "Aucune séance.");
} else {
for (index = 0U;
index < count && 4 + (int)index < LINES - 4;
++index) {
mvprintw(
4 + (int)index,
2,
"%-25s %2zu exercice(s)",
sessions[index].started_at,
sessions[index].exercise_count
);
}
}
wait_key();
}
static void prompt_body_metric(
int row,
const char *label,
bool *present,
double *value
)
{
(void)prompt_optional_double(row, label, present, value);
}
static void screen_body(TrainlogDatabase *database)
{
TrainlogBodyObservationInput observation;
char id[TRAINLOG_GENERATED_ID_CAPACITY];
char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
TrainlogStatus status;
int row = 4;
(void)memset(&observation, 0, sizeof(observation));
if (trainlog_id_generate("bo", id, sizeof(id)) != TRAINLOG_STATUS_OK ||
trainlog_time_now_rfc3339(timestamp, sizeof(timestamp)) !=
TRAINLOG_STATUS_OK) {
return;
}
(void)snprintf(
observation.observation_id,
sizeof(observation.observation_id),
"%s",
id
);
(void)snprintf(
observation.observed_at,
sizeof(observation.observed_at),
"%s",
timestamp
);
erase();
title("TRAINLOG — Corps / mensurations");
mvprintw(3, 2, "Laissez vide ce que vous ne mesurez pas aujourd'hui.");
prompt_body_metric(
row++,
"Poids kg : ",
&observation.has_body_weight,
&observation.body_weight_kg
);
prompt_body_metric(
row++,
"Tour de taille cm : ",
&observation.has_waist,
&observation.waist_cm
);
prompt_body_metric(
row++,
"Poitrine cm : ",
&observation.has_chest,
&observation.chest_cm
);
prompt_body_metric(
row++,
"Épaules cm : ",
&observation.has_shoulders,
&observation.shoulders_cm
);
prompt_body_metric(
row++,
"Bras gauche cm : ",
&observation.has_left_arm,
&observation.left_arm_cm
);
prompt_body_metric(
row++,
"Bras droit cm : ",
&observation.has_right_arm,
&observation.right_arm_cm
);
prompt_body_metric(
row++,
"Cuisse gauche cm : ",
&observation.has_left_thigh,
&observation.left_thigh_cm
);
prompt_body_metric(
row++,
"Cuisse droite cm : ",
&observation.has_right_thigh,
&observation.right_thigh_cm
);
prompt_body_metric(
row++,
"Mollet gauche cm : ",
&observation.has_left_calf,
&observation.left_calf_cm
);
prompt_body_metric(
row++,
"Mollet droit cm : ",
&observation.has_right_calf,
&observation.right_calf_cm
);
status = trainlog_database_insert_body_observation(
database,
&observation
);
if (status == TRAINLOG_STATUS_OK) {
status_line("✓ Mesures enregistrées.", TRAINLOG_COLOR_SUCCESS);
} else if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
status_line(
"Aucune mesure saisie : rien n'a été enregistré.",
TRAINLOG_COLOR_WARNING
);
} else {
status_line(
"Impossible d'enregistrer les mesures.",
TRAINLOG_COLOR_ERROR
);
}
wait_key();
}
int trainlog_tui_run(TrainlogDatabase *database)
{
int key;
if (database == NULL) {
return 1;
}
(void)setlocale(LC_ALL, "");
if (initscr() == NULL) {
return 1;
}
cbreak();
noecho();
keypad(stdscr, true);
curs_set(0);
trainlog_theme_initialize();
for (;;) {
if (LINES < 20 || COLS < 72) {
erase();
mvprintw(
1,
2,
"Terminal trop petit — minimum 72x20."
);
mvprintw(3, 2, "q pour quitter");
refresh();
key = getch();
if (key == 'q') {
break;
}
continue;
}
screen_dashboard(database);
key = getch();
switch (key) {
case '1':
screen_new_session(database);
break;
case '2':
screen_history(database);
break;
case '3':
screen_exercises(database);
break;
case '4':
screen_body(database);
break;
case 'q':
case 'Q':
endwin();
return 0;
default:
break;
}
}
endwin();
return 0;
}

84
tui/tests/test_catalog.c Normal file
View file

@ -0,0 +1,84 @@
/**
* @file test_catalog.c
* @brief Frozen v1 exercise normalization tests.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/catalog.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool test_normalization(void)
{
char first[512];
char second[512];
char third[512];
CHECK(
trainlog_catalog_normalize_name(
"Presse à cuisses",
first,
sizeof(first)
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_catalog_normalize_name(
" presse à cuisses ",
second,
sizeof(second)
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_catalog_normalize_name(
"PRESSE À CUISSES",
third,
sizeof(third)
) == TRAINLOG_STATUS_OK
);
CHECK(strcmp(first, second) == 0);
CHECK(strcmp(second, third) == 0);
return true;
}
static bool test_blank_rejected(void)
{
char normalized[64];
CHECK(
trainlog_catalog_normalize_name(
" \t \n ",
normalized,
sizeof(normalized)
) == TRAINLOG_STATUS_INVALID_ARGUMENT
);
return true;
}
int main(void)
{
CHECK(test_normalization());
(void)printf("PASS normalization\n");
CHECK(test_blank_rejected());
(void)printf("PASS blank_rejected\n");
return 0;
}

258
tui/tests/test_database.c Normal file
View file

@ -0,0 +1,258 @@
/**
* @file test_database.c
* @brief Black-box tests for Trainlog persistence.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/database.h"
#include "trainlog/id.h"
#define CHECK(condition) \
do { \
if (!(condition)) { \
(void)fprintf( \
stderr, \
"CHECK failed at %s:%d: %s\n", \
__FILE__, \
__LINE__, \
#condition \
); \
return false; \
} \
} while (0)
static bool test_database_open_and_schema(void)
{
TrainlogDatabase *database = NULL;
int schema_version = 0;
int foreign_keys = 0;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_schema_version(database, &schema_version) ==
TRAINLOG_STATUS_OK
);
CHECK(schema_version == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(
trainlog_database_foreign_keys_enabled(database, &foreign_keys) ==
TRAINLOG_STATUS_OK
);
CHECK(foreign_keys == 1);
trainlog_database_close(database);
return true;
}
static bool test_generated_ids(void)
{
char first[TRAINLOG_GENERATED_ID_CAPACITY];
char second[TRAINLOG_GENERATED_ID_CAPACITY];
CHECK(
trainlog_id_generate("ex", first, sizeof(first)) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_id_generate("ex", second, sizeof(second)) ==
TRAINLOG_STATUS_OK
);
CHECK(strncmp(first, "ex_", 3U) == 0);
CHECK(strlen(first) == TRAINLOG_GENERATED_ID_CAPACITY - 1U);
CHECK(strcmp(first, second) != 0);
CHECK(first[3U + 14U] == '4');
return true;
}
static bool seed_exercise(TrainlogDatabase *database)
{
return trainlog_database_insert_exercise(
database,
"ex_test",
"Presse à cuisses",
"presse à cuisses",
TRAINLOG_TRACKING_REPS
) == TRAINLOG_STATUS_OK;
}
static bool test_session_insert(void)
{
TrainlogDatabase *database = NULL;
TrainlogSetInput sets[2];
TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session;
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(seed_exercise(database));
(void)memset(sets, 0, sizeof(sets));
sets[0].reps = 5;
sets[0].has_weight = true;
sets[0].weight_kg = 80.0;
sets[1] = sets[0];
(void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(
exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_test"
);
exercise.load_mode = TRAINLOG_LOAD_EXTERNAL;
exercise.rest_seconds = 60;
exercise.target_sets = 2;
exercise.target_reps = 5;
exercise.target_has_weight = true;
exercise.target_weight_kg = 80.0;
exercise.sets = sets;
exercise.set_count = 2U;
(void)memset(&session, 0, sizeof(session));
(void)snprintf(
session.session_id,
sizeof(session.session_id),
"%s",
"se_test"
);
(void)snprintf(
session.started_at,
sizeof(session.started_at),
"%s",
"2026-09-05T18:00:00+02:00"
);
(void)snprintf(
session.ended_at,
sizeof(session.ended_at),
"%s",
"2026-09-05T19:00:00+02:00"
);
session.exercises = &exercise;
session.exercise_count = 1U;
CHECK(
trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_insert_session(database, &session) ==
TRAINLOG_STATUS_CONFLICT
);
CHECK(
trainlog_database_session_count(database, &count) ==
TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
trainlog_database_close(database);
return true;
}
static bool test_body_weight_history(void)
{
TrainlogDatabase *database = NULL;
TrainlogBodyObservationInput observation;
TrainlogWeightPoint points[4];
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
(void)memset(&observation, 0, sizeof(observation));
(void)snprintf(
observation.observation_id,
sizeof(observation.observation_id),
"%s",
"bo_test"
);
(void)snprintf(
observation.observed_at,
sizeof(observation.observed_at),
"%s",
"2026-09-05T07:00:00+02:00"
);
observation.has_body_weight = true;
observation.body_weight_kg = 82.4;
CHECK(
trainlog_database_insert_body_observation(
database,
&observation
) == TRAINLOG_STATUS_OK
);
CHECK(
trainlog_database_list_weight_points(
database,
points,
4U,
&count
) == TRAINLOG_STATUS_OK
);
CHECK(count == 1U);
CHECK(points[0].body_weight_kg > 82.39);
CHECK(points[0].body_weight_kg < 82.41);
trainlog_database_close(database);
return true;
}
static bool test_transaction_rollback(void)
{
TrainlogDatabase *database = NULL;
size_t count = 0U;
CHECK(
trainlog_database_open(":memory:", &database) ==
TRAINLOG_STATUS_OK
);
CHECK(trainlog_database_begin(database) == TRAINLOG_STATUS_OK);
CHECK(seed_exercise(database));
CHECK(trainlog_database_rollback(database) == TRAINLOG_STATUS_OK);
CHECK(
trainlog_database_exercise_count(database, &count) ==
TRAINLOG_STATUS_OK
);
CHECK(count == 0U);
trainlog_database_close(database);
return true;
}
struct TestCase {
const char *name;
bool (*function)(void);
};
int main(void)
{
static const struct TestCase tests[] = {
{"database_open_and_schema", test_database_open_and_schema},
{"generated_ids", test_generated_ids},
{"session_insert", test_session_insert},
{"body_weight_history", test_body_weight_history},
{"transaction_rollback", test_transaction_rollback},
};
size_t index;
for (index = 0U; index < sizeof(tests) / sizeof(tests[0]); ++index) {
if (!tests[index].function()) {
(void)fprintf(stderr, "FAIL %s\n", tests[index].name);
return 1;
}
(void)printf("PASS %s\n", tests[index].name);
}
return 0;
}