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.
|
||||
|
||||
The project uses a simple pre-release changelog during early development.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Initial repository structure and development contract.
|
||||
- Architecture, coding-style, database, Android, TUI, testing, and roadmap documentation.
|
||||
- Trainlog v1 JSON Schema draft.
|
||||
- Structural and semantic Trainlog validator.
|
||||
- Positive and negative exchange-format fixtures.
|
||||
- Gate 0 review and closure records.
|
||||
- Gate 1 exchange-format freeze-candidate review.
|
||||
- Gate 1 exercise-identity collision review.
|
||||
- Stable exercise tracking modes.
|
||||
- Explicit load modes for no load, external resistance, and assistance.
|
||||
- Optional bounded session and exercise notes.
|
||||
- Extended body measurement list.
|
||||
- Executable local catalog reconciliation contract.
|
||||
- UUIDv4 generation policy for new Trainlog exercise and session IDs.
|
||||
- Frozen Trainlog JSON v1 contract.
|
||||
- SQLite persistence foundation.
|
||||
- UUIDv4 Trainlog identifiers.
|
||||
- Frozen Unicode exercise-name normalization using utf8proc.
|
||||
- Direct exercise creation.
|
||||
- Direct workout recording in the TUI.
|
||||
- Automatic workout start/end timestamps.
|
||||
- Repetition and timed exercises.
|
||||
- None/external/assistance load entry.
|
||||
- Per-set actual performance entry.
|
||||
- Planned rest entry.
|
||||
- Body weight and common body measurement entry.
|
||||
- Session history.
|
||||
- Colorful ncursesw dashboard.
|
||||
- Compact terminal body-weight sparkline.
|
||||
- Catalog and persistence tests.
|
||||
|
||||
### Changed
|
||||
|
||||
- Gate 0 project contract is complete.
|
||||
- Gate 1 completed and Trainlog exchange format v1 frozen.
|
||||
- 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.
|
||||
- Development switches to larger vertical slices for the Monday 2026-09-07 05:00 usable-version target.
|
||||
- Minimal TUI work is pulled forward before Android so a functional fallback exists.
|
||||
|
|
|
|||
284
docs/database.md
284
docs/database.md
|
|
@ -1,106 +1,252 @@
|
|||
# Database
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
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:
|
||||
## 1. Status
|
||||
|
||||
```text
|
||||
exercises
|
||||
id internal SQLite primary key
|
||||
exercise_id stable Trainlog identifier
|
||||
name mutable display name
|
||||
GATE_2_REVIEW_01=IMPLEMENTED
|
||||
GATE_2=IN_PROGRESS
|
||||
DATABASE_SCHEMA_V1=DRAFT
|
||||
```
|
||||
|
||||
`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
|
||||
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;
|
||||
- migration required;
|
||||
- destructive and therefore forbidden without explicit migration logic.
|
||||
Canonical exercise catalog.
|
||||
|
||||
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
|
||||
|
||||
Multi-table imports must use transactions.
|
||||
Multi-row operations are atomic.
|
||||
|
||||
A failed import must not leave a partially inserted session.
|
||||
|
||||
Expected behavior:
|
||||
Gate 2 provides explicit:
|
||||
|
||||
```text
|
||||
BEGIN
|
||||
validate
|
||||
insert missing exercises
|
||||
insert session
|
||||
insert workout rows
|
||||
insert performed sets
|
||||
BEGIN IMMEDIATE
|
||||
COMMIT
|
||||
```
|
||||
|
||||
On failure:
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
Canonical storage units:
|
||||
Canonical persistent units remain:
|
||||
|
||||
- body weight: kilograms;
|
||||
- load: kilograms;
|
||||
- body measurements: centimeters;
|
||||
- duration: seconds.
|
||||
- weight/load: kilograms;
|
||||
- body circumference: centimeters;
|
||||
- duration/rest: 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;
|
||||
- preserve user data;
|
||||
- be testable from the previous supported version;
|
||||
- update the stored schema version only after success.
|
||||
Those belong to subsequent Gate 2 work.
|
||||
|
||||
This split keeps the first compiled C change small enough to review thoroughly.
|
||||
|
||||
## 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
|
||||
|
||||
Canonical result:
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
Status: PASS
|
||||
|
||||
Canonical result:
|
||||
|
||||
```text
|
||||
GATE_1_REVIEW_01=PASS
|
||||
GATE_1_REVIEW_02=PASS
|
||||
GATE_1=PASS
|
||||
TRAINLOG_FORMAT_V1=FROZEN
|
||||
```
|
||||
|
||||
Reviewed commits:
|
||||
## Gate 2 / Weekend MVP — Usable persistence + direct entry
|
||||
|
||||
Status: IN PROGRESS
|
||||
|
||||
Current vertical slice:
|
||||
|
||||
```text
|
||||
9d9a9223a0c46df72f5c3ab208107c0ac6698438
|
||||
dfd6717cb7978d009670f1a49029c62c9154af55
|
||||
WEEKEND_MVP_TUI=IMPLEMENTED
|
||||
GATE_2=IN_PROGRESS
|
||||
DATABASE_SCHEMA_V1=DRAFT
|
||||
```
|
||||
|
||||
Review #1 defined:
|
||||
Delivered in this slice:
|
||||
|
||||
- exercise identity and tracking mode;
|
||||
- session identity and ordering;
|
||||
- load semantics;
|
||||
- target versus actual work;
|
||||
- rest;
|
||||
- body weight and measurements;
|
||||
- notes;
|
||||
- strict document validation.
|
||||
|
||||
Review #2 closes:
|
||||
|
||||
- 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;
|
||||
- SQLite persistence foundation;
|
||||
- UUIDv4 creation;
|
||||
- frozen Unicode exercise-name normalization;
|
||||
- direct exercise creation;
|
||||
- direct session creation;
|
||||
- automatic start/end timestamps;
|
||||
- reps/duration tracking;
|
||||
- none/external/assistance load modes;
|
||||
- actual set recording;
|
||||
- planned rest;
|
||||
- standalone body measurements;
|
||||
- session history;
|
||||
- direct session entry;
|
||||
- import screen;
|
||||
- minimum-terminal fallback.
|
||||
- dashboard;
|
||||
- colored ncursesw interface;
|
||||
- weight sparkline.
|
||||
|
||||
Exit criteria:
|
||||
Validation required before push:
|
||||
|
||||
- usable color TUI;
|
||||
- monochrome fallback;
|
||||
- UTF-8 test pass.
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Deliverables:
|
||||
|
||||
- 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.
|
||||
Pulled forward immediately after Weekend MVP TUI.
|
||||
|
||||
## Gate 5 — Analytics
|
||||
|
||||
Deliverables:
|
||||
|
||||
- body-weight trend;
|
||||
- measurement trend;
|
||||
- exercise performance trend;
|
||||
- training volume summaries;
|
||||
- terminal graphs.
|
||||
- body-weight trends;
|
||||
- measurement trends;
|
||||
- exercise performance;
|
||||
- volume;
|
||||
- max/estimated max;
|
||||
- balance analysis.
|
||||
|
||||
## Gate 6 — Hardening
|
||||
|
||||
Deliverables:
|
||||
|
||||
- broader fixture coverage;
|
||||
- migrations;
|
||||
- import/export robustness;
|
||||
- documentation cleanup;
|
||||
- packaging;
|
||||
- broader tests;
|
||||
- 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