diff --git a/CHANGELOG.md b/CHANGELOG.md index 69bcad1..19a013d 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/docs/database.md b/docs/database.md index 90365bc..4ff9f6a 100644 --- a/docs/database.md +++ b/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_ +se_ +bo_ +``` + +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 +``` diff --git a/docs/reviews/gate2_review_01.md b/docs/reviews/gate2_review_01.md new file mode 100644 index 0000000..fdbab73 --- /dev/null +++ b/docs/reviews/gate2_review_01.md @@ -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 +``` diff --git a/docs/reviews/weekend_mvp_tui.md b/docs/reviews/weekend_mvp_tui.md new file mode 100644 index 0000000..e282094 --- /dev/null +++ b/docs/reviews/weekend_mvp_tui.md @@ -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 +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 425c8d8..e9d7854 100644 --- a/docs/roadmap.md +++ b/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. diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..c19f40d --- /dev/null +++ b/meson.build @@ -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') diff --git a/tui/.gitkeep b/tui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tui/include/trainlog/catalog.h b/tui/include/trainlog/catalog.h new file mode 100644 index 0000000..212960d --- /dev/null +++ b/tui/include/trainlog/catalog.h @@ -0,0 +1,43 @@ +#ifndef TRAINLOG_CATALOG_H +#define TRAINLOG_CATALOG_H + +/** + * @file catalog.h + * @brief Exercise-name normalization and canonical catalog creation. + */ + +#include + +#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 diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h new file mode 100644 index 0000000..c0aed58 --- /dev/null +++ b/tui/include/trainlog/database.h @@ -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 + +#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 diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h new file mode 100644 index 0000000..ce0d0ed --- /dev/null +++ b/tui/include/trainlog/id.h @@ -0,0 +1,51 @@ +#ifndef TRAINLOG_ID_H +#define TRAINLOG_ID_H + +/** + * @file id.h + * @brief Collision-resistant Trainlog identifier generation. + */ + +#include + +#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 `_`. 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 diff --git a/tui/include/trainlog/model.h b/tui/include/trainlog/model.h new file mode 100644 index 0000000..a3e5e84 --- /dev/null +++ b/tui/include/trainlog/model.h @@ -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 +#include + +#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 diff --git a/tui/include/trainlog/status.h b/tui/include/trainlog/status.h new file mode 100644 index 0000000..0cc1474 --- /dev/null +++ b/tui/include/trainlog/status.h @@ -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 diff --git a/tui/include/trainlog/theme.h b/tui/include/trainlog/theme.h new file mode 100644 index 0000000..9add527 --- /dev/null +++ b/tui/include/trainlog/theme.h @@ -0,0 +1,24 @@ +#ifndef TRAINLOG_THEME_H +#define TRAINLOG_THEME_H + +#include + +/** + * @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 diff --git a/tui/include/trainlog/timeutil.h b/tui/include/trainlog/timeutil.h new file mode 100644 index 0000000..59f238f --- /dev/null +++ b/tui/include/trainlog/timeutil.h @@ -0,0 +1,23 @@ +#ifndef TRAINLOG_TIMEUTIL_H +#define TRAINLOG_TIMEUTIL_H + +/** + * @file timeutil.h + * @brief RFC3339 local timestamp helpers. + */ + +#include + +#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 diff --git a/tui/include/trainlog/tui.h b/tui/include/trainlog/tui.h new file mode 100644 index 0000000..7cdfd4e --- /dev/null +++ b/tui/include/trainlog/tui.h @@ -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 diff --git a/tui/meson.build b/tui/meson.build new file mode 100644 index 0000000..acd69db --- /dev/null +++ b/tui/meson.build @@ -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, +) diff --git a/tui/src/catalog.c b/tui/src/catalog.c new file mode 100644 index 0000000..e395f64 --- /dev/null +++ b/tui/src/catalog.c @@ -0,0 +1,197 @@ +/** + * @file catalog.c + * @brief Frozen Trainlog v1 exercise-name normalization. + */ + +#include "trainlog/catalog.h" + +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/src/database.c b/tui/src/database.c new file mode 100644 index 0000000..124a7d3 --- /dev/null +++ b/tui/src/database.c @@ -0,0 +1,1205 @@ +/** + * @file database.c + * @brief SQLite persistence implementation for Trainlog. + */ + +#include "trainlog/database.h" + +#include +#include +#include +#include + +#include + +struct TrainlogDatabase { + sqlite3 *connection; +}; + +static const char *const SCHEMA_V1_SQL = + "BEGIN IMMEDIATE;" + + "CREATE TABLE IF NOT EXISTS exercises (" + " id INTEGER PRIMARY KEY," + " exercise_id TEXT NOT NULL UNIQUE," + " name TEXT NOT NULL," + " normalized_name TEXT NOT NULL UNIQUE," + " tracking_mode TEXT NOT NULL" + " CHECK (tracking_mode IN ('reps', 'duration'))" + ");" + + "CREATE TABLE IF NOT EXISTS sessions (" + " id INTEGER PRIMARY KEY," + " session_id TEXT NOT NULL UNIQUE," + " started_at TEXT NOT NULL," + " ended_at TEXT," + " notes TEXT" + ");" + + "CREATE TABLE IF NOT EXISTS session_exercises (" + " id INTEGER PRIMARY KEY," + " session_row_id INTEGER NOT NULL" + " REFERENCES sessions(id) ON DELETE CASCADE," + " exercise_row_id INTEGER NOT NULL" + " REFERENCES exercises(id) ON DELETE RESTRICT," + " position INTEGER NOT NULL CHECK (position >= 0)," + " load_mode TEXT NOT NULL" + " CHECK (load_mode IN ('none', 'external', 'assistance'))," + " rest_seconds INTEGER NOT NULL CHECK (rest_seconds >= 0)," + " target_sets INTEGER NOT NULL CHECK (target_sets > 0)," + " target_reps INTEGER CHECK (target_reps >= 1)," + " target_duration_seconds INTEGER" + " CHECK (target_duration_seconds > 0)," + " target_weight_kg REAL CHECK (target_weight_kg > 0.0)," + " notes TEXT," + " UNIQUE (session_row_id, position)," + " UNIQUE (session_row_id, exercise_row_id)," + " CHECK (" + " (target_reps IS NOT NULL AND" + " target_duration_seconds IS NULL) OR" + " (target_reps IS NULL AND" + " target_duration_seconds IS NOT NULL)" + " )," + " CHECK (" + " (load_mode = 'none' AND target_weight_kg IS NULL) OR" + " (load_mode IN ('external', 'assistance') AND" + " target_weight_kg IS NOT NULL)" + " )" + ");" + + "CREATE TABLE IF NOT EXISTS performed_sets (" + " id INTEGER PRIMARY KEY," + " session_exercise_row_id INTEGER NOT NULL" + " REFERENCES session_exercises(id) ON DELETE CASCADE," + " position INTEGER NOT NULL CHECK (position >= 0)," + " reps INTEGER CHECK (reps >= 0)," + " duration_seconds INTEGER CHECK (duration_seconds > 0)," + " weight_kg REAL CHECK (weight_kg > 0.0)," + " UNIQUE (session_exercise_row_id, position)," + " CHECK (" + " (reps IS NOT NULL AND duration_seconds IS NULL) OR" + " (reps IS NULL AND duration_seconds IS NOT NULL)" + " )" + ");" + + "CREATE TABLE IF NOT EXISTS body_observations (" + " id INTEGER PRIMARY KEY," + " observation_id TEXT NOT NULL UNIQUE," + " observed_at TEXT NOT NULL," + " session_row_id INTEGER UNIQUE" + " REFERENCES sessions(id) ON DELETE CASCADE," + " body_weight_kg REAL CHECK (body_weight_kg > 0.0)," + " neck_cm REAL CHECK (neck_cm > 0.0)," + " shoulders_cm REAL CHECK (shoulders_cm > 0.0)," + " chest_cm REAL CHECK (chest_cm > 0.0)," + " waist_cm REAL CHECK (waist_cm > 0.0)," + " hips_cm REAL CHECK (hips_cm > 0.0)," + " left_arm_cm REAL CHECK (left_arm_cm > 0.0)," + " right_arm_cm REAL CHECK (right_arm_cm > 0.0)," + " left_forearm_cm REAL CHECK (left_forearm_cm > 0.0)," + " right_forearm_cm REAL CHECK (right_forearm_cm > 0.0)," + " left_thigh_cm REAL CHECK (left_thigh_cm > 0.0)," + " right_thigh_cm REAL CHECK (right_thigh_cm > 0.0)," + " left_calf_cm REAL CHECK (left_calf_cm > 0.0)," + " right_calf_cm REAL CHECK (right_calf_cm > 0.0)," + " notes TEXT," + " CHECK (" + " body_weight_kg IS NOT NULL OR" + " neck_cm IS NOT NULL OR shoulders_cm IS NOT NULL OR" + " chest_cm IS NOT NULL OR waist_cm IS NOT NULL OR" + " hips_cm IS NOT NULL OR left_arm_cm IS NOT NULL OR" + " right_arm_cm IS NOT NULL OR left_forearm_cm IS NOT NULL OR" + " right_forearm_cm IS NOT NULL OR left_thigh_cm IS NOT NULL OR" + " right_thigh_cm IS NOT NULL OR left_calf_cm IS NOT NULL OR" + " right_calf_cm IS NOT NULL" + " )" + ");" + + "PRAGMA user_version = 1;" + "COMMIT;"; + +static TrainlogStatus execute_sql( + TrainlogDatabase *database, + const char *sql +) +{ + if (database == NULL || database->connection == NULL || sql == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + if (sqlite3_exec(database->connection, sql, NULL, NULL, NULL) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus read_single_int_pragma( + TrainlogDatabase *database, + const char *sql, + int *output +) +{ + sqlite3_stmt *statement = NULL; + int rc; + + if (database == NULL || + database->connection == NULL || + sql == NULL || + output == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc != SQLITE_ROW) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output = sqlite3_column_int(statement, 0); + + rc = sqlite3_finalize(statement); + return rc == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +static TrainlogStatus initialize_or_validate_schema( + TrainlogDatabase *database +) +{ + int version = 0; + TrainlogStatus status; + + status = trainlog_database_schema_version(database, &version); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + if (version > TRAINLOG_DATABASE_SCHEMA_VERSION) { + return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; + } + + if (version == TRAINLOG_DATABASE_SCHEMA_VERSION) { + return TRAINLOG_STATUS_OK; + } + + if (version != 0) { + return TRAINLOG_STATUS_SCHEMA_UNSUPPORTED; + } + + status = execute_sql(database, SCHEMA_V1_SQL); + if (status != TRAINLOG_STATUS_OK) { + (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); + } + + return status; +} + +TrainlogStatus trainlog_database_open( + const char *path, + TrainlogDatabase **output_database +) +{ + TrainlogDatabase *database; + int rc; + TrainlogStatus status; + + if (path == NULL || path[0] == '\0' || output_database == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + *output_database = NULL; + + database = calloc(1U, sizeof(*database)); + if (database == NULL) { + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + + rc = sqlite3_open_v2( + path, + &database->connection, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + NULL + ); + if (rc != SQLITE_OK) { + trainlog_database_close(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_busy_timeout(database->connection, 5000) != SQLITE_OK) { + trainlog_database_close(database); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + status = execute_sql(database, "PRAGMA foreign_keys = ON;"); + if (status != TRAINLOG_STATUS_OK) { + trainlog_database_close(database); + return status; + } + + status = initialize_or_validate_schema(database); + if (status != TRAINLOG_STATUS_OK) { + trainlog_database_close(database); + return status; + } + + *output_database = database; + return TRAINLOG_STATUS_OK; +} + +void trainlog_database_close(TrainlogDatabase *database) +{ + if (database == NULL) { + return; + } + + if (database->connection != NULL) { + (void)sqlite3_close(database->connection); + } + + free(database); +} + +TrainlogStatus trainlog_database_schema_version( + TrainlogDatabase *database, + int *output_version +) +{ + return read_single_int_pragma( + database, + "PRAGMA user_version;", + output_version + ); +} + +TrainlogStatus trainlog_database_foreign_keys_enabled( + TrainlogDatabase *database, + int *output_enabled +) +{ + return read_single_int_pragma( + database, + "PRAGMA foreign_keys;", + output_enabled + ); +} + +TrainlogStatus trainlog_database_begin(TrainlogDatabase *database) +{ + return execute_sql(database, "BEGIN IMMEDIATE;"); +} + +TrainlogStatus trainlog_database_commit(TrainlogDatabase *database) +{ + return execute_sql(database, "COMMIT;"); +} + +TrainlogStatus trainlog_database_rollback(TrainlogDatabase *database) +{ + return execute_sql(database, "ROLLBACK;"); +} + +static const char *tracking_mode_to_sql(TrainlogTrackingMode mode) +{ + switch (mode) { + case TRAINLOG_TRACKING_REPS: + return "reps"; + case TRAINLOG_TRACKING_DURATION: + return "duration"; + default: + return NULL; + } +} + +static const char *load_mode_to_sql(TrainlogLoadMode mode) +{ + switch (mode) { + case TRAINLOG_LOAD_NONE: + return "none"; + case TRAINLOG_LOAD_EXTERNAL: + return "external"; + case TRAINLOG_LOAD_ASSISTANCE: + return "assistance"; + default: + return NULL; + } +} + +TrainlogStatus trainlog_database_insert_exercise( + TrainlogDatabase *database, + const char *exercise_id, + const char *name, + const char *normalized_name, + TrainlogTrackingMode tracking_mode +) +{ + static const char *const SQL = + "INSERT INTO exercises(" + "exercise_id, name, normalized_name, tracking_mode" + ") VALUES(?1, ?2, ?3, ?4);"; + sqlite3_stmt *statement = NULL; + const char *mode; + int rc; + + if (database == NULL || + database->connection == NULL || + exercise_id == NULL || + exercise_id[0] == '\0' || + name == NULL || + name[0] == '\0' || + normalized_name == NULL || + normalized_name[0] == '\0') { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + mode = tracking_mode_to_sql(tracking_mode); + if (mode == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, name, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 3, normalized_name, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 4, mode, -1, SQLITE_STATIC) != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +static TrainlogStatus count_query( + TrainlogDatabase *database, + const char *sql, + size_t *output_count +) +{ + sqlite3_stmt *statement = NULL; + sqlite3_int64 count; + int rc; + + if (database == NULL || + database->connection == NULL || + sql == NULL || + output_count == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc != SQLITE_ROW) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + count = sqlite3_column_int64(statement, 0); + if (count < 0 || (uint64_t)count > (uint64_t)SIZE_MAX) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_count = (size_t)count; + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_exercise_count( + TrainlogDatabase *database, + size_t *output_count +) +{ + return count_query( + database, + "SELECT COUNT(*) FROM exercises;", + output_count + ); +} + +TrainlogStatus trainlog_database_session_count( + TrainlogDatabase *database, + size_t *output_count +) +{ + return count_query( + database, + "SELECT COUNT(*) FROM sessions;", + output_count + ); +} + +static TrainlogTrackingMode tracking_mode_from_sql(const char *text) +{ + return text != NULL && strcmp(text, "duration") == 0 + ? TRAINLOG_TRACKING_DURATION + : TRAINLOG_TRACKING_REPS; +} + +TrainlogStatus trainlog_database_list_exercises( + TrainlogDatabase *database, + TrainlogExercise *output, + size_t capacity, + size_t *output_count +) +{ + static const char *const SQL = + "SELECT exercise_id, name, tracking_mode " + "FROM exercises ORDER BY name COLLATE NOCASE, exercise_id;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + + if (database == NULL || + database->connection == NULL || + output_count == NULL || + (capacity > 0U && output == NULL)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + if (count < capacity) { + const unsigned char *id = sqlite3_column_text(statement, 0); + const unsigned char *name = sqlite3_column_text(statement, 1); + const unsigned char *mode = sqlite3_column_text(statement, 2); + + if (id == NULL || name == NULL || mode == NULL) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + (void)snprintf( + output[count].exercise_id, + sizeof(output[count].exercise_id), + "%s", + (const char *)id + ); + (void)snprintf( + output[count].name, + sizeof(output[count].name), + "%s", + (const char *)name + ); + output[count].tracking_mode = + tracking_mode_from_sql((const char *)mode); + } + ++count; + } + + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_count = count < capacity ? count : capacity; + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus lookup_exercise_row_id( + TrainlogDatabase *database, + const char *exercise_id, + sqlite3_int64 *output_row_id +) +{ + sqlite3_stmt *statement = NULL; + int rc; + + rc = sqlite3_prepare_v2( + database->connection, + "SELECT id FROM exercises WHERE exercise_id = ?1;", + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_bind_text( + statement, + 1, + exercise_id, + -1, + SQLITE_TRANSIENT + ) != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc == SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_NOT_FOUND; + } + if (rc != SQLITE_ROW) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_row_id = sqlite3_column_int64(statement, 0); + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +static TrainlogStatus insert_session_header( + TrainlogDatabase *database, + const TrainlogSessionInput *session, + sqlite3_int64 *output_row_id +) +{ + sqlite3_stmt *statement = NULL; + int rc; + + rc = sqlite3_prepare_v2( + database->connection, + "INSERT INTO sessions(session_id, started_at, ended_at, notes) " + "VALUES(?1, ?2, ?3, ?4);", + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_bind_text(statement, 1, session->session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_text(statement, 2, session->started_at, -1, SQLITE_TRANSIENT) != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (session->ended_at[0] != '\0') { + rc = sqlite3_bind_text( + statement, + 3, + session->ended_at, + -1, + SQLITE_TRANSIENT + ); + } else { + rc = sqlite3_bind_null(statement, 3); + } + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (session->notes != NULL && session->notes[0] != '\0') { + rc = sqlite3_bind_text(statement, 4, session->notes, -1, SQLITE_TRANSIENT); + } else { + rc = sqlite3_bind_null(statement, 4); + } + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_row_id = sqlite3_last_insert_rowid(database->connection); + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus insert_session_exercise( + TrainlogDatabase *database, + sqlite3_int64 session_row_id, + size_t position, + const TrainlogSessionExerciseInput *input, + sqlite3_int64 *output_row_id +) +{ + sqlite3_stmt *statement = NULL; + sqlite3_int64 exercise_row_id; + const char *load_mode; + int rc; + TrainlogStatus status; + + load_mode = load_mode_to_sql(input->load_mode); + if (load_mode == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = lookup_exercise_row_id( + database, + input->exercise_id, + &exercise_row_id + ); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + rc = sqlite3_prepare_v2( + database->connection, + "INSERT INTO session_exercises(" + "session_row_id, exercise_row_id, position, load_mode, " + "rest_seconds, target_sets, target_reps, " + "target_duration_seconds, target_weight_kg, notes" + ") VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);", + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_int64(statement, 1, session_row_id); + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int64(statement, 2, exercise_row_id); + } + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int64(statement, 3, (sqlite3_int64)position); + } + if (rc == SQLITE_OK) { + rc = sqlite3_bind_text(statement, 4, load_mode, -1, SQLITE_STATIC); + } + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int(statement, 5, input->rest_seconds); + } + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int(statement, 6, input->target_sets); + } + if (rc == SQLITE_OK) { + rc = input->target_reps > 0 + ? sqlite3_bind_int(statement, 7, input->target_reps) + : sqlite3_bind_null(statement, 7); + } + if (rc == SQLITE_OK) { + rc = input->target_duration_seconds > 0 + ? sqlite3_bind_int( + statement, + 8, + input->target_duration_seconds + ) + : sqlite3_bind_null(statement, 8); + } + if (rc == SQLITE_OK) { + rc = input->target_has_weight + ? sqlite3_bind_double(statement, 9, input->target_weight_kg) + : sqlite3_bind_null(statement, 9); + } + if (rc == SQLITE_OK) { + rc = input->notes != NULL && input->notes[0] != '\0' + ? sqlite3_bind_text( + statement, + 10, + input->notes, + -1, + SQLITE_TRANSIENT + ) + : sqlite3_bind_null(statement, 10); + } + + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_row_id = sqlite3_last_insert_rowid(database->connection); + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus insert_performed_set( + TrainlogDatabase *database, + sqlite3_int64 session_exercise_row_id, + size_t position, + const TrainlogSetInput *input +) +{ + sqlite3_stmt *statement = NULL; + int rc; + + rc = sqlite3_prepare_v2( + database->connection, + "INSERT INTO performed_sets(" + "session_exercise_row_id, position, reps, duration_seconds, weight_kg" + ") VALUES(?1, ?2, ?3, ?4, ?5);", + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_int64(statement, 1, session_exercise_row_id); + if (rc == SQLITE_OK) { + rc = sqlite3_bind_int64(statement, 2, (sqlite3_int64)position); + } + if (rc == SQLITE_OK) { + rc = input->duration_seconds > 0 + ? sqlite3_bind_null(statement, 3) + : sqlite3_bind_int(statement, 3, input->reps); + } + if (rc == SQLITE_OK) { + rc = input->duration_seconds > 0 + ? sqlite3_bind_int(statement, 4, input->duration_seconds) + : sqlite3_bind_null(statement, 4); + } + if (rc == SQLITE_OK) { + rc = input->has_weight + ? sqlite3_bind_double(statement, 5, input->weight_kg) + : sqlite3_bind_null(statement, 5); + } + + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_insert_session( + TrainlogDatabase *database, + const TrainlogSessionInput *session +) +{ + sqlite3_int64 session_row_id; + size_t exercise_index; + TrainlogStatus status; + + if (database == NULL || + database->connection == NULL || + session == NULL || + session->session_id[0] == '\0' || + session->started_at[0] == '\0' || + (session->exercise_count > 0U && session->exercises == NULL)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + status = trainlog_database_begin(database); + if (status != TRAINLOG_STATUS_OK) { + return status; + } + + status = insert_session_header(database, session, &session_row_id); + if (status != TRAINLOG_STATUS_OK) { + (void)trainlog_database_rollback(database); + return status; + } + + for (exercise_index = 0U; + exercise_index < session->exercise_count; + ++exercise_index) { + const TrainlogSessionExerciseInput *exercise = + &session->exercises[exercise_index]; + sqlite3_int64 session_exercise_row_id; + size_t set_index; + + status = insert_session_exercise( + database, + session_row_id, + exercise_index, + exercise, + &session_exercise_row_id + ); + if (status != TRAINLOG_STATUS_OK) { + (void)trainlog_database_rollback(database); + return status; + } + + for (set_index = 0U; set_index < exercise->set_count; ++set_index) { + status = insert_performed_set( + database, + session_exercise_row_id, + set_index, + &exercise->sets[set_index] + ); + if (status != TRAINLOG_STATUS_OK) { + (void)trainlog_database_rollback(database); + return status; + } + } + } + + status = trainlog_database_commit(database); + if (status != TRAINLOG_STATUS_OK) { + (void)trainlog_database_rollback(database); + } + + return status; +} + +TrainlogStatus trainlog_database_list_sessions( + TrainlogDatabase *database, + TrainlogSessionSummary *output, + size_t capacity, + size_t *output_count +) +{ + static const char *const SQL = + "SELECT s.session_id, s.started_at, COALESCE(s.ended_at, ''), " + "COUNT(se.id) " + "FROM sessions AS s " + "LEFT JOIN session_exercises AS se ON se.session_row_id = s.id " + "GROUP BY s.id " + "ORDER BY s.started_at DESC, s.id DESC;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + + if (database == NULL || + database->connection == NULL || + output_count == NULL || + (capacity > 0U && output == NULL)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + if (count < capacity) { + const unsigned char *id = sqlite3_column_text(statement, 0); + const unsigned char *started = sqlite3_column_text(statement, 1); + const unsigned char *ended = sqlite3_column_text(statement, 2); + sqlite3_int64 exercise_count = sqlite3_column_int64(statement, 3); + + if (id == NULL || started == NULL || ended == NULL || + exercise_count < 0) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + (void)snprintf( + output[count].session_id, + sizeof(output[count].session_id), + "%s", + (const char *)id + ); + (void)snprintf( + output[count].started_at, + sizeof(output[count].started_at), + "%s", + (const char *)started + ); + (void)snprintf( + output[count].ended_at, + sizeof(output[count].ended_at), + "%s", + (const char *)ended + ); + output[count].exercise_count = (size_t)exercise_count; + } + ++count; + } + + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_count = count < capacity ? count : capacity; + return TRAINLOG_STATUS_OK; +} + +static int bind_optional_double( + sqlite3_stmt *statement, + int index, + bool present, + double value +) +{ + return present + ? sqlite3_bind_double(statement, index, value) + : sqlite3_bind_null(statement, index); +} + +TrainlogStatus trainlog_database_insert_body_observation( + TrainlogDatabase *database, + const TrainlogBodyObservationInput *observation +) +{ + static const char *const SQL = + "INSERT INTO body_observations(" + "observation_id, observed_at, session_row_id, 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" + ") VALUES(" + "?1, ?2, " + "(SELECT id FROM sessions WHERE session_id = ?3), " + "?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18" + ");"; + sqlite3_stmt *statement = NULL; + bool any_metric; + int rc; + + if (database == NULL || + database->connection == NULL || + observation == NULL || + observation->observation_id[0] == '\0' || + observation->observed_at[0] == '\0') { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + any_metric = + observation->has_body_weight || + observation->has_neck || + observation->has_shoulders || + observation->has_chest || + observation->has_waist || + observation->has_hips || + observation->has_left_arm || + observation->has_right_arm || + observation->has_left_forearm || + observation->has_right_forearm || + observation->has_left_thigh || + observation->has_right_thigh || + observation->has_left_calf || + observation->has_right_calf; + + if (!any_metric) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_bind_text( + statement, + 1, + observation->observation_id, + -1, + SQLITE_TRANSIENT + ); + if (rc == SQLITE_OK) { + rc = sqlite3_bind_text( + statement, + 2, + observation->observed_at, + -1, + SQLITE_TRANSIENT + ); + } + if (rc == SQLITE_OK) { + rc = observation->session_id != NULL && + observation->session_id[0] != '\0' + ? sqlite3_bind_text( + statement, + 3, + observation->session_id, + -1, + SQLITE_TRANSIENT + ) + : sqlite3_bind_null(statement, 3); + } + +#define BIND_METRIC(index_, flag_, value_) \ + do { \ + if (rc == SQLITE_OK) { \ + rc = bind_optional_double( \ + statement, \ + (index_), \ + (flag_), \ + (value_) \ + ); \ + } \ + } while (0) + + BIND_METRIC(4, observation->has_body_weight, observation->body_weight_kg); + BIND_METRIC(5, observation->has_neck, observation->neck_cm); + BIND_METRIC(6, observation->has_shoulders, observation->shoulders_cm); + BIND_METRIC(7, observation->has_chest, observation->chest_cm); + BIND_METRIC(8, observation->has_waist, observation->waist_cm); + BIND_METRIC(9, observation->has_hips, observation->hips_cm); + BIND_METRIC(10, observation->has_left_arm, observation->left_arm_cm); + BIND_METRIC(11, observation->has_right_arm, observation->right_arm_cm); + BIND_METRIC( + 12, + observation->has_left_forearm, + observation->left_forearm_cm + ); + BIND_METRIC( + 13, + observation->has_right_forearm, + observation->right_forearm_cm + ); + BIND_METRIC(14, observation->has_left_thigh, observation->left_thigh_cm); + BIND_METRIC(15, observation->has_right_thigh, observation->right_thigh_cm); + BIND_METRIC(16, observation->has_left_calf, observation->left_calf_cm); + BIND_METRIC(17, observation->has_right_calf, observation->right_calf_cm); + +#undef BIND_METRIC + + if (rc == SQLITE_OK) { + rc = observation->notes != NULL && observation->notes[0] != '\0' + ? sqlite3_bind_text( + statement, + 18, + observation->notes, + -1, + SQLITE_TRANSIENT + ) + : sqlite3_bind_null(statement, 18); + } + + if (rc != SQLITE_OK) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + rc = sqlite3_step(statement); + if (rc == SQLITE_CONSTRAINT) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + return sqlite3_finalize(statement) == SQLITE_OK + ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_database_list_weight_points( + TrainlogDatabase *database, + TrainlogWeightPoint *output, + size_t capacity, + size_t *output_count +) +{ + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + + if (database == NULL || + database->connection == NULL || + output_count == NULL || + (capacity > 0U && output == NULL)) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + + rc = sqlite3_prepare_v2( + database->connection, + "SELECT observed_at, body_weight_kg " + "FROM body_observations " + "WHERE body_weight_kg IS NOT NULL " + "ORDER BY observed_at ASC, id ASC;", + -1, + &statement, + NULL + ); + if (rc != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + if (count < capacity) { + const unsigned char *observed = sqlite3_column_text(statement, 0); + if (observed == NULL) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + (void)snprintf( + output[count].observed_at, + sizeof(output[count].observed_at), + "%s", + (const char *)observed + ); + output[count].body_weight_kg = + sqlite3_column_double(statement, 1); + } + ++count; + } + + if (rc != SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + *output_count = count < capacity ? count : capacity; + return TRAINLOG_STATUS_OK; +} diff --git a/tui/src/id.c b/tui/src/id.c new file mode 100644 index 0000000..7cb11f9 --- /dev/null +++ b/tui/src/id.c @@ -0,0 +1,66 @@ +/** + * @file id.c + * @brief Trainlog UUIDv4 identifier generation. + */ + +#include "trainlog/id.h" + +#include +#include +#include + +#include + +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; +} diff --git a/tui/src/main.c b/tui/src/main.c new file mode 100644 index 0000000..4379173 --- /dev/null +++ b/tui/src/main.c @@ -0,0 +1,108 @@ +/** + * @file main.c + * @brief Trainlog TUI process entry point. + */ + +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/tui/src/theme.c b/tui/src/theme.c new file mode 100644 index 0000000..e2f295a --- /dev/null +++ b/tui/src/theme.c @@ -0,0 +1,34 @@ +/** + * @file theme.c + * @brief Centralized Trainlog ncurses colors. + */ + +#include "trainlog/theme.h" + +#include + +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); +} diff --git a/tui/src/timeutil.c b/tui/src/timeutil.c new file mode 100644 index 0000000..29a5cd7 --- /dev/null +++ b/tui/src/timeutil.c @@ -0,0 +1,76 @@ +/** + * @file timeutil.c + * @brief Local RFC3339 timestamp generation. + */ + +#include "trainlog/timeutil.h" + +#include +#include +#include + +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; +} diff --git a/tui/src/tui.c b/tui/src/tui.c new file mode 100644 index 0000000..ce9ab75 --- /dev/null +++ b/tui/src/tui.c @@ -0,0 +1,989 @@ +/** + * @file tui.c + * @brief First usable Trainlog ncurses interface. + */ + +#include "trainlog/tui.h" + +#include +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/tests/test_catalog.c b/tui/tests/test_catalog.c new file mode 100644 index 0000000..d2609a4 --- /dev/null +++ b/tui/tests/test_catalog.c @@ -0,0 +1,84 @@ +/** + * @file test_catalog.c + * @brief Frozen v1 exercise normalization tests. + */ + +#include +#include +#include + +#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; +} diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c new file mode 100644 index 0000000..929353b --- /dev/null +++ b/tui/tests/test_database.c @@ -0,0 +1,258 @@ +/** + * @file test_database.c + * @brief Black-box tests for Trainlog persistence. + */ + +#include +#include +#include + +#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; +}