Add first usable Trainlog TUI
This commit is contained in:
parent
3d5f9a2bef
commit
3e36b89d6b
25 changed files with 4081 additions and 208 deletions
43
CHANGELOG.md
43
CHANGELOG.md
|
|
@ -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.
|
|
||||||
|
|
|
||||||
284
docs/database.md
284
docs/database.md
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
146
docs/reviews/gate2_review_01.md
Normal file
146
docs/reviews/gate2_review_01.md
Normal 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
|
||||||
|
```
|
||||||
116
docs/reviews/weekend_mvp_tui.md
Normal file
116
docs/reviews/weekend_mvp_tui.md
Normal 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
|
||||||
|
```
|
||||||
184
docs/roadmap.md
184
docs/roadmap.md
|
|
@ -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
13
meson.build
Normal 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')
|
||||||
43
tui/include/trainlog/catalog.h
Normal file
43
tui/include/trainlog/catalog.h
Normal 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
|
||||||
94
tui/include/trainlog/database.h
Normal file
94
tui/include/trainlog/database.h
Normal 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
51
tui/include/trainlog/id.h
Normal 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
|
||||||
115
tui/include/trainlog/model.h
Normal file
115
tui/include/trainlog/model.h
Normal 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
|
||||||
26
tui/include/trainlog/status.h
Normal file
26
tui/include/trainlog/status.h
Normal 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
|
||||||
24
tui/include/trainlog/theme.h
Normal file
24
tui/include/trainlog/theme.h
Normal 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
|
||||||
23
tui/include/trainlog/timeutil.h
Normal file
23
tui/include/trainlog/timeutil.h
Normal 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
|
||||||
13
tui/include/trainlog/tui.h
Normal file
13
tui/include/trainlog/tui.h
Normal 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
97
tui/meson.build
Normal 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
197
tui/src/catalog.c
Normal 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
1205
tui/src/database.c
Normal file
File diff suppressed because it is too large
Load diff
66
tui/src/id.c
Normal file
66
tui/src/id.c
Normal 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
108
tui/src/main.c
Normal 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
34
tui/src/theme.c
Normal 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
76
tui/src/timeutil.c
Normal 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
989
tui/src/tui.c
Normal 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
84
tui/tests/test_catalog.c
Normal 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
258
tui/tests/test_database.c
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue