From 456713586b8631964ee76a743e589cae4672f79a Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 16 Sep 2026 08:26:48 +0200 Subject: [PATCH] refactor(core): extract dashboard read model --- docs/architecture.md | 18 +- docs/current_state.md | 22 +- docs/roadmap.md | 19 +- docs/tests.md | 8 +- docs/tui.md | 11 + tui/include/trainlog/dashboard.h | 104 ++++ tui/meson.build | 17 + tui/src/dashboard.c | 717 ++++++++++++++++++++++ tui/src/tui.c | 809 ++----------------------- tui/tests/test_dashboard.c | 514 ++++++++++++++++ tui/tests/test_dashboard_header.c | 9 + tui/tests/test_presentation_catalog.py | 2 +- tui/tests/test_tui_workflows.c | 565 +---------------- 13 files changed, 1487 insertions(+), 1328 deletions(-) create mode 100644 tui/include/trainlog/dashboard.h create mode 100644 tui/src/dashboard.c create mode 100644 tui/tests/test_dashboard.c create mode 100644 tui/tests/test_dashboard_header.c diff --git a/docs/architecture.md b/docs/architecture.md index ce9cc78..d357371 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,14 +119,16 @@ layout is compact from 72x20, has a sidebar from 100x26, and expands it from and run-scoped terminal planes keep Notcurses infrastructure separate from product semantics. -The current Dashboard implementation retains one known layering violation: -`tui.c` uses SQLite and `database_internal.h` directly to build Dashboard facts -and calculations. `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` now captures -that behavior, including its bounds and legacy-data flags. This remains a -blocking debt for the Web Dashboard: before any Dashboard HTTP endpoint exists, -the query and calculations must move incrementally into a typed Core read model -and the TUI must consume that read model. This requirement does not authorize a -global `tui.c` refactor. +`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` resolves the former Dashboard +layering violation. `tui/src/dashboard.c` alone owns its SQLite fact projection, +normalization, bounds, ordering, rolling windows, aggregates and legacy-data +flags. Its public `trainlog/dashboard.h` boundary contains no SQLite, Notcurses +or private TUI type. The TUI calls that service with an explicit reference Unix +second and presents the caller-owned bounded snapshot; it neither includes +`database_internal.h` for the Dashboard nor reproduces its calculations. +`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` was completed in the same tranche +because live TUI adoption is the parity proof. This was an incremental +extraction, not a global `tui.c` refactor. ### Local Web application diff --git a/docs/current_state.md b/docs/current_state.md index 8fd283e..5b264fe 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -37,6 +37,8 @@ parallel implementation of its rules. | Application shell | `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` | | Statistics | `STATS_V1=IMPLEMENTED` | | Dashboard characterization | `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` | +| Dashboard Core read model | `WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` | +| TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen @@ -125,12 +127,10 @@ The desktop is a strict C17 application using Notcurses. It currently provides: including selected-language formatting without changing canonical data. Rendering is not permitted to own SQL or business rules. Statistics are -read-only projections; they are not persisted as facts. The current TUI -Dashboard still has a known exception: its direct SQLite/ -`database_internal.h` fact projection is now frozen by characterization tests. -It must next be extracted into a typed Core read model and consumed by the TUI -before any Web Dashboard endpoint is created. This is an incremental -extraction, not authorization for a global `tui.c` refactor. +read-only projections; they are not persisted as facts. The former Dashboard +exception is resolved: its typed Core read model owns the SQLite projection and +all characterized calculations, while the TUI supplies query state and renders +the result. No Dashboard HTTP endpoint exists yet. ## Local Web @@ -165,6 +165,10 @@ import-contract validator. The source-derived TUI `TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and diff safety checks pass in the final audit evidence. +The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and +**62/62 ASan/UBSan Meson tests**, including the standalone public-header and +Core characterization targets, plus both canonical JSON validators. + `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence, formatting, layout-invariance and source-derived text-boundary tests, plus Android resource-parity, language-owner, typed sync-presentation, and @@ -177,9 +181,9 @@ smoke test are not automated. The latter is why ## Active limitations -- `TRAINLOG_WEB_V1` is contract-only; Dashboard characterization is frozen and - the next implementation step is Core read-model extraction, not React or - HTTP infrastructure. +- `TRAINLOG_WEB_V1` remains contract-only; Dashboard characterization, Core + extraction and TUI adoption are frozen. The next implementation cursor is + CLI/local HTTP infrastructure, not React. - `APP_SHELL_V1` still awaits the recorded human visual/accessibility review. - AI proposal exchange still awaits one real Drive plus Android-triggered bidirectional smoke test. diff --git a/docs/roadmap.md b/docs/roadmap.md index df5bdb8..0eb6c8f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,14 +30,17 @@ architecture is canonical, but no Web code exists yet. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_CORE_READ_MODEL_V1 +CURRENT_OPERATIONAL_CURSOR=WEB_CLI_HTTP_INFRASTRUCTURE_V1 ``` -`WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN`. Move the now-characterized -direct SQLite and `database_internal.h` projection into a typed Core read -model without changing its results. Then adapt the TUI to consume that read -model. This incremental sequence is a blocking prerequisite for a Web -Dashboard endpoint and does not authorize a global `tui.c` refactor. +`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` and +`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN`. The characterized Dashboard SQL, +facts, limits and calculations now live in the typed Core service +`trainlog_dashboard_load()`; the TUI supplies the selected period and current +time, then presents the returned snapshot. Adoption was necessary to prove +parity and was therefore completed in the extraction tranche rather than left +as an empty roadmap step. The next authorized slice is local CLI/HTTP +infrastructure; it does not yet authorize React or Dashboard Web metrics. ## TRAINLOG_WEB_V1 implementation gate @@ -45,8 +48,8 @@ Implementation order is fixed as: ```text WEB_DASHBOARD_CHARACTERIZATION_V1 - -> WEB_DASHBOARD_CORE_READ_MODEL_V1 - -> WEB_TUI_READ_MODEL_ADOPTION_V1 + -> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN] + -> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN] -> WEB_CLI_HTTP_INFRASTRUCTURE_V1 -> WEB_FRONTEND_SHELL_V1 -> WEB_DASHBOARD_V1 diff --git a/docs/tests.md b/docs/tests.md index 0022ec6..b4f1bb7 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -188,8 +188,9 @@ tests (198 passed, 4 skipped, 0 failed), with both JSON validators passing. Automated coverage does not replace a real device/manual visual language-switch smoke; that remains explicit manual validation. -`WEB_DASHBOARD_CHARACTERIZATION_V1` exercises the production Dashboard -projection through a real in-memory SQLite database. Its fixed test clock +`WEB_DASHBOARD_CHARACTERIZATION_V1` now exercises the production Core service +directly through a real in-memory SQLite database, without including `tui.c` or +linking Notcurses. Its explicit fixed query instant covers an empty training history, exact rolling-window inclusion immediately before/at/after the 30-day boundary, valid actual history without `ended_at`, and plan-only exclusion. It covers repeated occurrences, heterogeneous REPS @@ -203,6 +204,9 @@ sessions and 4096 Dashboard facts, including the independently truncated totals and `partial` flag. Existing regressions continue to cover malformed legacy timestamps, body-metric selection, responsive rendering, all-history buckets, strictly earlier exact-dose improvement, and explicit-MAX separation. +The standalone `dashboard_header` target also proves that +`trainlog/dashboard.h` compiles independently as C17. TUI workflow regressions +separately verify service adoption and snapshot presentation. Notable regression coverage: diff --git a/docs/tui.md b/docs/tui.md index c2c5f7b..06a8bdb 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -449,6 +449,17 @@ no set-position tie-break; the preceding fact order is session, entry, kind, then set position. The Core extraction must initially preserve these observed semantics or explicitly return to contract review before changing them. +`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` preserves those semantics in +the Core API `trainlog_dashboard_load()`. Its query makes the reference Unix +second explicit, so rolling boundaries are deterministic without a test-only +production clock. The snapshot is caller-owned and retains the characterized +128-session, 4096-fact, 128-performance-point, 256-body-point and 12-bucket +bounds plus conservative `partial` and `invalid_data` flags. The TUI owns only +period selection, the normal `time(NULL)` capture, localization, formatting, +layout, rendering and interaction. Consequently +`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` is complete as part of the same +extraction tranche. + The snapshot reads at most 128 observable sessions, 4096 occurrence facts, 128 selected performance points, and 256 points for each body metric. Reaching a bound sets the conservative `partial` flag. At exactly 128 sessions the view is diff --git a/tui/include/trainlog/dashboard.h b/tui/include/trainlog/dashboard.h new file mode 100644 index 0000000..bcabc34 --- /dev/null +++ b/tui/include/trainlog/dashboard.h @@ -0,0 +1,104 @@ +/* + * Trainlog Dashboard Core read model. + * + * This public boundary deliberately exposes neither SQLite nor TUI types. + */ +#ifndef TRAINLOG_DASHBOARD_H +#define TRAINLOG_DASHBOARD_H + +#include +#include +#include + +#include "trainlog/database.h" + +#define TRAINLOG_DASHBOARD_SESSION_CAPACITY 128U +#define TRAINLOG_DASHBOARD_FACT_CAPACITY 4096U +#define TRAINLOG_DASHBOARD_PERFORMANCE_CAPACITY 128U +#define TRAINLOG_DASHBOARD_BODY_CAPACITY 256U +#define TRAINLOG_DASHBOARD_BUCKET_CAPACITY 12U +#define TRAINLOG_DASHBOARD_ZONE_CAPACITY 17U +#define TRAINLOG_DASHBOARD_BODY_METRIC_COUNT 14U + +typedef enum TrainlogDashboardPeriod { + TRAINLOG_DASHBOARD_30_DAYS = 0, + TRAINLOG_DASHBOARD_7_DAYS, + TRAINLOG_DASHBOARD_90_DAYS, + TRAINLOG_DASHBOARD_YEAR, + TRAINLOG_DASHBOARD_ALL +} TrainlogDashboardPeriod; + +typedef struct TrainlogDashboardQuery { + TrainlogDashboardPeriod period; + /* CONTRACT: explicit UTC Unix second used by every rolling window and by + * the final presentation bucket. This makes one query deterministic. */ + int64_t reference_unix_second; +} TrainlogDashboardQuery; + +typedef struct TrainlogDashboardBucket { + int64_t start_day; + int64_t span_days; + size_t sessions; + size_t maxima; + size_t working_improvements; + size_t max_improvements; +} TrainlogDashboardBucket; + +typedef struct TrainlogDashboardZoneBucket { + /* Empty means the explicit unclassified bucket. */ + char zone_id[TRAINLOG_ZONE_ID_MAX + 1U]; + size_t count; +} TrainlogDashboardZoneBucket; + +typedef struct TrainlogDashboardSnapshot { + bool error; + bool partial; + bool invalid_data; + bool has_performance; + TrainlogExercise exercise; + TrainlogLoadMode performance_load_mode; + TrainlogTrackingMode performance_tracking_mode; + int performance_dose; + char performance_equipment_id[TRAINLOG_ID_MAX + 1U]; + char performance_equipment_label[TRAINLOG_NAME_MAX + 1U]; + TrainlogExercisePerformancePoint + performance[TRAINLOG_DASHBOARD_PERFORMANCE_CAPACITY]; + size_t performance_count; + bool has_explicit_max; + TrainlogExercise max_exercise; + double max_weight_kg; + char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; + bool has_body; + size_t body_metric; + TrainlogBodyMetricPoint body[TRAINLOG_DASHBOARD_BODY_CAPACITY]; + size_t body_count; + TrainlogDashboardBucket weeks[TRAINLOG_DASHBOARD_BUCKET_CAPACITY]; + size_t week_count; + size_t completed_session_count; + size_t performed_set_count; + size_t distinct_exercise_count; + size_t explicit_max_count; + /* CONTRACT: current canonical catalogue, primary zone only; aliases and + * secondary zones never contribute. Missing primary zones are explicit. */ + TrainlogDashboardZoneBucket zones[TRAINLOG_DASHBOARD_ZONE_CAPACITY]; + size_t zone_count; + TrainlogDashboardPeriod period; +} TrainlogDashboardSnapshot; + +/* + * WHY: all interfaces must consume one canonical Dashboard projection. + * CONTRACT: database/query/snapshot are borrowed and required. The function + * initializes the complete caller-owned snapshot and retains no pointer or + * SQLite resource. Invalid periods return INVALID_ARGUMENT. A non-OK result + * sets snapshot->error; partial and invalid_data remain independent quality + * flags preserving the frozen legacy projection. + * INVARIANT: capacities and ordering are part of + * WEB_DASHBOARD_CHARACTERIZATION_V1 and must not change implicitly. + */ +TrainlogStatus trainlog_dashboard_load( + TrainlogDatabase *database, + const TrainlogDashboardQuery *query, + TrainlogDashboardSnapshot *snapshot +); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 46c5733..7001346 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -90,6 +90,7 @@ trainlog_core_sources = files( 'src/body_analytics.c', 'src/catalog.c', 'src/database.c', + 'src/dashboard.c', 'src/duration.c', 'src/id.c', 'src/timeutil.c', @@ -200,6 +201,22 @@ test_body_zones = executable( ) test('body_zones', test_body_zones) +test_dashboard = executable( + 'test_dashboard', + 'tests/test_dashboard.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('dashboard', test_dashboard) + +test_dashboard_header = executable( + 'test_dashboard_header', + 'tests/test_dashboard_header.c', + include_directories: trainlog_include, + c_args: strict_c_args, +) +test('dashboard_header', test_dashboard_header) + test_training_knowledge = executable( 'test_training_knowledge', 'tests/test_training_knowledge.c', diff --git a/tui/src/dashboard.c b/tui/src/dashboard.c new file mode 100644 index 0000000..8222130 --- /dev/null +++ b/tui/src/dashboard.c @@ -0,0 +1,717 @@ +/** + * @file dashboard.c + * @brief Canonical bounded Dashboard read model. + */ + +#include "trainlog/dashboard.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "trainlog/body_zone_catalog.h" +#include "timestamp.h" +#include "database_internal.h" + +#define DASHBOARD_BODY_ZONE_RELATION_CAPACITY 16U + +typedef enum TrainlogDashboardFactKind { + TRAINLOG_DASHBOARD_WORK, + TRAINLOG_DASHBOARD_MAX, + TRAINLOG_DASHBOARD_CONTINUOUS +} TrainlogDashboardFactKind; + +typedef struct TrainlogDashboardFact { + TrainlogDashboardFactKind kind; + TrainlogTimestampKey time; + char session_id[TRAINLOG_ID_MAX + 1U]; + char entry_id[TRAINLOG_ID_MAX + 1U]; + char exercise_id[TRAINLOG_ID_MAX + 1U]; + char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char equipment_id[TRAINLOG_ID_MAX + 1U]; + TrainlogTrackingMode tracking_mode; + TrainlogLoadMode load_mode; + size_t set_position; + int dose; + bool has_weight; + double weight_kg; +} TrainlogDashboardFact; + +static const TrainlogBodyMetric DASHBOARD_BODY_METRICS[] = { + TRAINLOG_BODY_METRIC_WEIGHT, TRAINLOG_BODY_METRIC_NECK, + TRAINLOG_BODY_METRIC_SHOULDERS, TRAINLOG_BODY_METRIC_CHEST, + TRAINLOG_BODY_METRIC_WAIST, TRAINLOG_BODY_METRIC_HIPS, + TRAINLOG_BODY_METRIC_LEFT_ARM, TRAINLOG_BODY_METRIC_RIGHT_ARM, + TRAINLOG_BODY_METRIC_LEFT_FOREARM, TRAINLOG_BODY_METRIC_RIGHT_FOREARM, + TRAINLOG_BODY_METRIC_LEFT_THIGH, TRAINLOG_BODY_METRIC_RIGHT_THIGH, + TRAINLOG_BODY_METRIC_LEFT_CALF, TRAINLOG_BODY_METRIC_RIGHT_CALF +}; + +_Static_assert(sizeof(DASHBOARD_BODY_METRICS) / + sizeof(DASHBOARD_BODY_METRICS[0]) == + TRAINLOG_DASHBOARD_BODY_METRIC_COUNT, + "Dashboard body metric contract must remain complete"); + +static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column, + char *output, size_t capacity) +{ + const unsigned char *value; + int bytes; + if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false; + value = sqlite3_column_text(statement, column); + bytes = sqlite3_column_bytes(statement, column); + if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false; + (void)memcpy(output, value, (size_t)bytes); + output[bytes] = '\0'; + return true; +} + +/* WHY: the global dashboard compares occurrence-owned facts, while the public + * performance reader deliberately collapses each session to one representative. + * CONTRACT: this private projection emits every performed set, every explicit + * MAX exactly once, and every continuous occurrence exactly once. INVARIANT: + * malformed legacy timestamps are omitted before sorting and reported through + * invalid_data; they can never reach a comparator with uninitialized keys. */ +static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database, + TrainlogDashboardFact *output, size_t capacity, size_t *output_count, + bool *truncated, bool *invalid_data) +{ + static const char *const SQL = + "SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id," + "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position," + "CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END," + "ps.weight_kg FROM performed_sets ps " + "JOIN session_exercises se ON se.id=ps.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "UNION ALL " + "SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id," + "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0," + "mr.max_weight_kg FROM max_results mr " + "JOIN session_exercises se ON se.id=mr.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "UNION ALL " + "SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id," + "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0," + "ca.duration_seconds,NULL FROM continuous_activity ca " + "JOIN session_exercises se ON se.id=ca.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id;"; + sqlite3_stmt *statement = NULL; + size_t count = 0U; + int rc; + if (output_count != NULL) *output_count = 0U; + if (truncated != NULL) *truncated = false; + if (invalid_data != NULL) *invalid_data = false; + if (database == NULL || database->connection == NULL || output_count == NULL || + truncated == NULL || invalid_data == 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) { + TrainlogDashboardFact fact; + const unsigned char *tracking; + const unsigned char *load; + int kind; + (void)memset(&fact, 0, sizeof(fact)); + if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || + !dashboard_copy_sql_text(statement, 1, fact.started_at, + sizeof(fact.started_at)) || + !dashboard_copy_sql_text(statement, 2, fact.session_id, + sizeof(fact.session_id)) || + !dashboard_copy_sql_text(statement, 3, fact.entry_id, + sizeof(fact.entry_id)) || + !dashboard_copy_sql_text(statement, 4, fact.exercise_id, + sizeof(fact.exercise_id)) || + !dashboard_copy_sql_text(statement, 5, fact.equipment_id, + sizeof(fact.equipment_id)) || + sqlite3_column_type(statement, 6) != SQLITE_TEXT || + sqlite3_column_type(statement, 7) != SQLITE_TEXT || + !trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at), + &fact.time)) { + *invalid_data = true; + continue; + } + kind = sqlite3_column_int(statement, 0); + if (kind < 0 || kind > 2) { + *invalid_data = true; + continue; + } + fact.kind = (TrainlogDashboardFactKind)kind; + tracking = sqlite3_column_text(statement, 6); + load = sqlite3_column_text(statement, 7); + if (strcmp((const char *)tracking, "reps") == 0) + fact.tracking_mode = TRAINLOG_TRACKING_REPS; + else if (strcmp((const char *)tracking, "duration") == 0) + fact.tracking_mode = TRAINLOG_TRACKING_DURATION; + else { *invalid_data = true; continue; } + if (strcmp((const char *)load, "none") == 0) + fact.load_mode = TRAINLOG_LOAD_NONE; + else if (strcmp((const char *)load, "external") == 0) + fact.load_mode = TRAINLOG_LOAD_EXTERNAL; + else if (strcmp((const char *)load, "assistance") == 0) + fact.load_mode = TRAINLOG_LOAD_ASSISTANCE; + else { *invalid_data = true; continue; } + if (fact.kind == TRAINLOG_DASHBOARD_WORK) { + sqlite3_int64 position; + if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER || + sqlite3_column_type(statement, 9) != SQLITE_INTEGER) { + *invalid_data = true; continue; + } + position = sqlite3_column_int64(statement, 8); + if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) { + *invalid_data = true; continue; + } + fact.set_position = (size_t)position; + fact.dose = sqlite3_column_int(statement, 9); + } else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) { + if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) { + *invalid_data = true; continue; + } + fact.dose = sqlite3_column_int(statement, 9); + } + if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) { + int type = sqlite3_column_type(statement, 10); + if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) { + fact.has_weight = false; + } else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) || + !isfinite(sqlite3_column_double(statement, 10))) { + *invalid_data = true; continue; + } else { + fact.has_weight = true; + fact.weight_kg = sqlite3_column_double(statement, 10); + } + if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) { + *invalid_data = true; continue; + } + } + if (count == capacity) { *truncated = true; continue; } + output[count++] = fact; + } + if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + *output_count = count; + return TRAINLOG_STATUS_OK; +} + +static int dashboard_fact_time_compare(const void *left, const void *right) +{ + const TrainlogDashboardFact *a = left; + const TrainlogDashboardFact *b = right; + int result = trainlog_timestamp_compare(&a->time, &b->time); + if (result != 0) return result; + result = strcmp(a->session_id, b->session_id); + if (result != 0) return result; + result = strcmp(a->entry_id, b->entry_id); + if (result != 0) return result; + if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1; + return a->set_position == b->set_position ? 0 : + (a->set_position < b->set_position ? -1 : 1); +} + +static int dashboard_performance_time_compare(const void *left, const void *right) +{ + const TrainlogExercisePerformancePoint *a = left; + const TrainlogExercisePerformancePoint *b = right; + TrainlogTimestampKey a_key; + TrainlogTimestampKey b_key; + (void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key); + (void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key); + { + int result = trainlog_timestamp_compare(&a_key, &b_key); + return result != 0 ? result : strcmp(a->session_id, b->session_id); + } +} + +static int dashboard_body_time_compare(const void *left, const void *right) +{ + const TrainlogBodyMetricPoint *a = left; + const TrainlogBodyMetricPoint *b = right; + TrainlogTimestampKey a_key; + TrainlogTimestampKey b_key; + (void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key); + (void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key); + return trainlog_timestamp_compare(&a_key, &b_key); +} + +static int64_t dashboard_floor_div(int64_t value, int64_t divisor) +{ + return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor); +} + +static int64_t dashboard_period_days(TrainlogDashboardPeriod period) +{ + switch (period) { + case TRAINLOG_DASHBOARD_7_DAYS: return 7; + case TRAINLOG_DASHBOARD_30_DAYS: return 30; + case TRAINLOG_DASHBOARD_90_DAYS: return 90; + case TRAINLOG_DASHBOARD_YEAR: return 365; + case TRAINLOG_DASHBOARD_ALL: return 0; + } + return 30; +} + +static bool dashboard_in_period(const TrainlogTimestampKey *key, + TrainlogDashboardPeriod period, int64_t reference_unix_second) +{ + const int64_t days = dashboard_period_days(period); + /* Unix time and Trainlog's proleptic day zero differ by 719162 days. */ + const int64_t now = reference_unix_second + + INT64_C(719162) * INT64_C(86400); + return key->utc_second <= now && + (days == 0 || key->utc_second >= now - days * INT64_C(86400)); +} + +/* WHY: the selected period must change both dashboard charts, while the + * canonical rolling membership test above remains the sole inclusion rule. + * CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day + * and roughly monthly (31-day) presentation buckets; Tout uses at most twelve + * equal day spans covering represented history. INVARIANT: every included + * local date is clamped into exactly one leading-to-current bucket. */ +static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard, + int64_t current_day, int64_t earliest_day) +{ + size_t count; + int64_t span; + size_t index; + switch (dashboard->period) { + case TRAINLOG_DASHBOARD_7_DAYS: count = 7U; span = 1; break; + case TRAINLOG_DASHBOARD_30_DAYS: count = 6U; span = 5; break; + case TRAINLOG_DASHBOARD_90_DAYS: count = 6U; span = 15; break; + case TRAINLOG_DASHBOARD_YEAR: count = 12U; span = 31; break; + case TRAINLOG_DASHBOARD_ALL: { + int64_t represented = current_day >= earliest_day + ? current_day - earliest_day + 1 : 1; + count = represented < (int64_t)TRAINLOG_DASHBOARD_BUCKET_CAPACITY + ? (size_t)represented : TRAINLOG_DASHBOARD_BUCKET_CAPACITY; + if (count == 0U) count = 1U; + span = (represented + (int64_t)count - 1) / (int64_t)count; + break; + } + } + dashboard->week_count = count; + for (index = 0U; index < count; ++index) { + int64_t distance = (int64_t)(count - 1U - index) * span; + dashboard->weeks[index].start_day = current_day - distance - span + 1; + dashboard->weeks[index].span_days = span; + } +} + +static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard, + int64_t local_day) +{ + int64_t first; + int64_t span; + size_t slot; + if (dashboard->week_count == 0U) return 0U; + first = dashboard->weeks[0].start_day; + span = dashboard->weeks[0].span_days; + if (local_day <= first) return 0U; + slot = (size_t)((local_day - first) / span); + return slot < dashboard->week_count ? slot : dashboard->week_count - 1U; +} + +static bool dashboard_fact_external_context_qualifies(TrainlogDatabase *database, + const TrainlogDashboardFact *fact) +{ + TrainlogResolvedEquipment equipment; + if (fact->equipment_id[0] == '\0' || + trainlog_database_resolve_equipment(database, fact->equipment_id, + &equipment) != TRAINLOG_STATUS_OK) return false; + return strcmp(equipment.load_semantics, "external") == 0; +} + +static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises, + size_t count, const char *exercise_id) +{ + size_t index; + for (index = 0U; index < count; ++index) + if (strcmp(exercises[index].exercise_id, exercise_id) == 0) + return &exercises[index]; + return NULL; +} + +static int dashboard_zone_bucket_compare(const void *left, const void *right) +{ + const TrainlogDashboardZoneBucket *a = left; + const TrainlogDashboardZoneBucket *b = right; + const TrainlogBodyZone *a_zone; + const TrainlogBodyZone *b_zone; + const char *a_label; + const char *b_label; + if (a->count != b->count) return a->count > b->count ? -1 : 1; + a_zone = trainlog_body_zone_catalog_lookup(a->zone_id); + b_zone = trainlog_body_zone_catalog_lookup(b->zone_id); + a_label = a_zone == NULL ? "Non classés" : a_zone->display_name; + b_label = b_zone == NULL ? "Non classés" : b_zone->display_name; + return strcmp(a_label, b_label); +} + +/* WHY: a body-zone dashboard must describe the current catalogue rather than + * infer classifications from history or commercial names. CONTRACT: one + * canonical exercise is counted once, using only its persisted primary body + * zone. INVARIANT: missing primary relations remain visible as "Non classés"; + * secondary relations and aliases can neither create nor duplicate a bucket. */ +static bool dashboard_load_zone_distribution(TrainlogDatabase *database, TrainlogDashboardSnapshot *dashboard, + const TrainlogExercise *exercises, size_t exercise_count) +{ + size_t exercise_index; + for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) { + TrainlogExerciseBodyZone + relations[DASHBOARD_BODY_ZONE_RELATION_CAPACITY]; + const char *zone_id = ""; + size_t relation_count = 0U; + size_t relation_index; + size_t bucket; + if (trainlog_database_list_exercise_body_zones(database, + exercises[exercise_index].exercise_id, relations, + DASHBOARD_BODY_ZONE_RELATION_CAPACITY, + &relation_count) != TRAINLOG_STATUS_OK) return false; + for (relation_index = 0U; relation_index < relation_count; ++relation_index) { + if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup( + relations[relation_index].zone_id); + if (zone != NULL && !zone->is_group) zone_id = zone->zone_id; + break; + } + } + for (bucket = 0U; bucket < dashboard->zone_count; ++bucket) + if (strcmp(dashboard->zones[bucket].zone_id, zone_id) == 0) break; + if (bucket == dashboard->zone_count) { + if (bucket == TRAINLOG_DASHBOARD_ZONE_CAPACITY) return false; + (void)memcpy(dashboard->zones[bucket].zone_id, zone_id, + strlen(zone_id) + 1U); + ++dashboard->zone_count; + } + ++dashboard->zones[bucket].count; + } + qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]), + dashboard_zone_bucket_compare); + return true; +} + +/* WHY: the landing page must remain useful without rendering-time SQL. + * CONTRACT: this bounded controller snapshot contains only actual persisted + * facts. One working-performance series has an exact exercise/equipment/load + * identity; explicit MAX is separate, and assistance/planned targets never + * enter it. All chronology is decided by Trainlog's timestamp parser, not + * SQLite text ordering. */ +TrainlogStatus trainlog_dashboard_load( + TrainlogDatabase *database, const TrainlogDashboardQuery *query, + TrainlogDashboardSnapshot *dashboard) +{ + const int64_t epoch_offset = INT64_C(719162) * INT64_C(86400); + TrainlogDashboardPeriod period; + TrainlogExercise *exercises = NULL; + TrainlogDashboardFact *facts = NULL; + TrainlogBodyMetricPoint body[TRAINLOG_DASHBOARD_BODY_CAPACITY]; + TrainlogSessionSummary sessions[TRAINLOG_DASHBOARD_SESSION_CAPACITY]; + TrainlogTimestampKey newest_performance; + char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = ""; + size_t exercise_count = 0U; + size_t fact_count = 0U; + size_t session_count = 0U; + size_t exercise_index; + int64_t current_day; + int64_t earliest_day = INT64_MAX; + bool has_newest_performance = false; + bool facts_truncated = false; + bool facts_invalid = false; + + if (dashboard == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + (void)memset(dashboard, 0, sizeof(*dashboard)); + if (database == NULL || query == NULL || + query->period < TRAINLOG_DASHBOARD_30_DAYS || + query->period > TRAINLOG_DASHBOARD_ALL || + query->reference_unix_second > INT64_MAX - epoch_offset) { + dashboard->error = true; + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + period = query->period; + dashboard->period = period; + facts = calloc(TRAINLOG_DASHBOARD_FACT_CAPACITY, sizeof(*facts)); + if (facts == NULL) { + dashboard->error = true; + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + if (trainlog_database_read_snapshot_begin(database) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + free(facts); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (trainlog_database_list_sessions(database, sessions, TRAINLOG_DASHBOARD_SESSION_CAPACITY, + &session_count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + dashboard->partial = session_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY; + /* CONTRACT: frequency is one count per actual session; a MAX marker is an + * attribute of that count and never a second session. Rolling membership + * uses canonical instants, while buckets use represented local dates. + * Presentation always ends at the canonical current day, so the bucket + * containing that day remains explicit even when it is zero. */ + current_day = dashboard_floor_div(query->reference_unix_second + + INT64_C(719162) * INT64_C(86400), INT64_C(86400)); + for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) { + TrainlogTimestampKey key; + if (!trainlog_timestamp_parse(sessions[exercise_index].started_at, + strlen(sessions[exercise_index].started_at), &key)) { + dashboard->invalid_data = true; + continue; + } + if (!dashboard_in_period(&key, dashboard->period, query->reference_unix_second)) continue; + ++dashboard->completed_session_count; + if (key.local_day < earliest_day) earliest_day = key.local_day; + } + dashboard_prepare_period_buckets(dashboard, current_day, + earliest_day == INT64_MAX ? current_day : earliest_day); + for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) { + TrainlogTimestampKey key; + size_t slot; + if (!trainlog_timestamp_parse(sessions[exercise_index].started_at, + strlen(sessions[exercise_index].started_at), &key) || + !dashboard_in_period(&key, dashboard->period, query->reference_unix_second)) continue; + slot = dashboard_period_bucket(dashboard, key.local_day); + ++dashboard->weeks[slot].sessions; + if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST) + ++dashboard->weeks[slot].maxima; + } + if (trainlog_database_list_exercises(database, NULL, 0U, + &exercise_count) != TRAINLOG_STATUS_OK || + exercise_count > SIZE_MAX / sizeof(*exercises)) { + dashboard->error = true; + goto finish; + } + if (exercise_count > 0U) { + exercises = calloc(exercise_count, sizeof(*exercises)); + if (exercises == NULL || trainlog_database_list_exercises(database, + exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + } + if (!dashboard_load_zone_distribution(database, dashboard, exercises, exercise_count)) { + dashboard->error = true; + goto finish; + } + if (dashboard_database_list_facts(database, facts, + TRAINLOG_DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated, + &facts_invalid) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + dashboard->partial = dashboard->partial || facts_truncated; + dashboard->invalid_data = dashboard->invalid_data || facts_invalid; + qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare); + for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) { + TrainlogDashboardFact *fact = &facts[exercise_index]; + TrainlogExercise *exercise; + bool external; + bool comparable_work; + bool comparable_max; + size_t prior; + bool has_prior = false; + double prior_best = 0.0; + exercise = dashboard_find_exercise(exercises, exercise_count, + fact->exercise_id); + if (!dashboard_in_period(&fact->time, dashboard->period, query->reference_unix_second)) continue; + if (fact->kind == TRAINLOG_DASHBOARD_WORK) + ++dashboard->performed_set_count; + else if (fact->kind == TRAINLOG_DASHBOARD_MAX) + ++dashboard->explicit_max_count; + { + size_t seen; + for (seen = 0U; seen < exercise_index; ++seen) + if (dashboard_in_period(&facts[seen].time, dashboard->period, query->reference_unix_second) && + strcmp(facts[seen].exercise_id, fact->exercise_id) == 0) + break; + if (seen == exercise_index) ++dashboard->distinct_exercise_count; + } + external = dashboard_fact_external_context_qualifies(database, fact); + comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external && + fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 && + fact->has_weight && isfinite(fact->weight_kg) && + fact->weight_kg >= 0.0; + comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external && + fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0; + + /* CONTRACT: Android and TUI compare the same actual observations: + * exact exercise + nonempty resolved external equipment + external + * load semantics + exact performed dose. Only facts at a strictly + * earlier canonical instant establish the prior best; bytewise IDs + * order equal-instant presentation but never manufacture an event. */ + if (comparable_work || comparable_max) { + for (prior = 0U; prior < exercise_index; ++prior) { + TrainlogDashboardFact *old = &facts[prior]; + bool same = old->kind == fact->kind && + strcmp(old->exercise_id, fact->exercise_id) == 0 && + strcmp(old->equipment_id, fact->equipment_id) == 0 && + old->tracking_mode == fact->tracking_mode && + old->load_mode == fact->load_mode && + (fact->kind != TRAINLOG_DASHBOARD_WORK || + old->dose == fact->dose); + if (!same || trainlog_timestamp_compare(&old->time, + &fact->time) >= 0) continue; + if (!has_prior || old->weight_kg > prior_best) + prior_best = old->weight_kg; + has_prior = true; + } + if (has_prior && fact->weight_kg > prior_best && + dashboard->week_count > 0U) { + size_t slot = dashboard_period_bucket(dashboard, + fact->time.local_day); + if (comparable_work) + ++dashboard->weeks[slot].working_improvements; + else ++dashboard->weeks[slot].max_improvements; + } + } + if (comparable_max && exercise != NULL) { + TrainlogTimestampKey current_max; + int order = 1; + if (dashboard->has_explicit_max && + trainlog_timestamp_parse(dashboard->max_timestamp, + strlen(dashboard->max_timestamp), ¤t_max)) + order = trainlog_timestamp_compare(&fact->time, ¤t_max); + if (!dashboard->has_explicit_max || order > 0 || + (order == 0 && strcmp(fact->exercise_id, + dashboard->max_exercise.exercise_id) < 0)) { + dashboard->has_explicit_max = true; + dashboard->max_exercise = *exercise; + dashboard->max_weight_kg = fact->weight_kg; + (void)snprintf(dashboard->max_timestamp, + sizeof(dashboard->max_timestamp), "%s", fact->started_at); + } + } + if (comparable_work && exercise != NULL) { + int order = has_newest_performance ? + trainlog_timestamp_compare(&fact->time, &newest_performance) : 1; + if (!has_newest_performance || order > 0 || + (order == 0 && strcmp(fact->exercise_id, + dashboard->exercise.exercise_id) < 0)) { + dashboard->has_performance = true; + dashboard->exercise = *exercise; + dashboard->performance_load_mode = fact->load_mode; + dashboard->performance_tracking_mode = fact->tracking_mode; + dashboard->performance_dose = fact->dose; + (void)snprintf(dashboard->performance_equipment_id, + sizeof(dashboard->performance_equipment_id), "%s", + fact->equipment_id); + newest_performance = fact->time; + has_newest_performance = true; + } + } + } + if (dashboard->has_performance) { + TrainlogResolvedEquipment equipment; + size_t index; + if (trainlog_database_resolve_equipment(database, + dashboard->performance_equipment_id, &equipment) == + TRAINLOG_STATUS_OK) + (void)snprintf(dashboard->performance_equipment_label, + sizeof(dashboard->performance_equipment_label), "%s", + equipment.display_name); + for (index = 0U; index < fact_count && + dashboard->performance_count < TRAINLOG_DASHBOARD_SESSION_CAPACITY; ++index) { + TrainlogDashboardFact *fact = &facts[index]; + TrainlogExercisePerformancePoint *point; + if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 || + fact->load_mode != dashboard->performance_load_mode || + fact->tracking_mode != dashboard->performance_tracking_mode || + fact->dose != dashboard->performance_dose || + strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 || + strcmp(fact->equipment_id, + dashboard->performance_equipment_id) != 0 || + !dashboard_in_period(&fact->time, dashboard->period, query->reference_unix_second)) continue; + point = &dashboard->performance[dashboard->performance_count++]; + (void)memset(point, 0, sizeof(*point)); + (void)snprintf(point->session_id, sizeof(point->session_id), "%s", + fact->session_id); + (void)snprintf(point->started_at, sizeof(point->started_at), "%s", + fact->started_at); + point->tracking_mode = fact->tracking_mode; + point->load_mode = fact->load_mode; + point->actual_set_count = 1U; + point->has_performance = 1; + point->metric_value = fact->dose; + point->has_weight = 1; + point->weight_kg = fact->weight_kg; + (void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s", + fact->equipment_id); + } + if (dashboard->performance_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY) + dashboard->partial = true; + qsort(dashboard->performance, dashboard->performance_count, + sizeof(dashboard->performance[0]), dashboard_performance_time_compare); + } + + for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) { + size_t count = 0U; + size_t point_index; + if (trainlog_database_list_body_metric_points(database, + DASHBOARD_BODY_METRICS[exercise_index], body, + TRAINLOG_DASHBOARD_BODY_CAPACITY, &count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + continue; + } + if (count == TRAINLOG_DASHBOARD_BODY_CAPACITY) dashboard->partial = true; + for (point_index = 0U; point_index < count; ++point_index) { + TrainlogTimestampKey candidate; + TrainlogTimestampKey current; + int order = 1; + if (!trainlog_timestamp_parse(body[point_index].observed_at, + strlen(body[point_index].observed_at), &candidate)) { + dashboard->invalid_data = true; + continue; + } + if (!dashboard_in_period(&candidate, dashboard->period, query->reference_unix_second)) continue; + if (newest_body[0] != '\0') { + (void)trainlog_timestamp_parse(newest_body, strlen(newest_body), ¤t); + order = trainlog_timestamp_compare(&candidate, ¤t); + } + if (newest_body[0] == '\0' || order > 0 || + (order == 0 && exercise_index < dashboard->body_metric)) { + dashboard->has_body = true; + dashboard->body_metric = exercise_index; + (void)snprintf(newest_body, sizeof(newest_body), "%s", + body[point_index].observed_at); + } + } + } + if (dashboard->has_body) { + size_t count = 0U; + size_t point_index; + if (trainlog_database_list_body_metric_points(database, + DASHBOARD_BODY_METRICS[dashboard->body_metric], body, + TRAINLOG_DASHBOARD_BODY_CAPACITY, &count) != TRAINLOG_STATUS_OK) { + dashboard->error = true; + goto finish; + } + if (count == TRAINLOG_DASHBOARD_BODY_CAPACITY) + dashboard->partial = true; + for (point_index = 0U; point_index < count; ++point_index) { + TrainlogTimestampKey key; + if (!trainlog_timestamp_parse(body[point_index].observed_at, + strlen(body[point_index].observed_at), &key)) { + dashboard->invalid_data = true; + continue; + } + if (dashboard_in_period(&key, dashboard->period, query->reference_unix_second)) + dashboard->body[dashboard->body_count++] = body[point_index]; + } + qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]), + dashboard_body_time_compare); + } + +finish: + if (trainlog_database_read_snapshot_end(database, !dashboard->error) != + TRAINLOG_STATUS_OK) dashboard->error = true; + free(exercises); + free(facts); + return dashboard->error ? TRAINLOG_STATUS_DATABASE_ERROR : TRAINLOG_STATUS_OK; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index 9be47b2..415e5b8 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -24,7 +24,6 @@ #include #include -#include #include "trainlog/terminal.h" #include "trainlog/app_shell.h" @@ -36,6 +35,7 @@ #include "trainlog/body_zone_catalog.h" #include "trainlog/catalog.h" #include "trainlog/duration.h" +#include "trainlog/dashboard.h" #include "trainlog/equipment_catalog.h" #include "trainlog/exercise_name_catalog.h" #include "trainlog/id.h" @@ -53,7 +53,6 @@ #include "trainlog/training_knowledge.h" #include "trainlog/usb.h" #include "timestamp.h" -#include "database_internal.h" static void presentation_surface_printf(TrainlogSurface *surface, int row, int column, const char *format, ...) @@ -104,9 +103,6 @@ static void presentation_terminal_printf(TrainlogTerminal *terminal, int row, #define MAX_BODY_METRIC_POINTS 256U #define EXERCISE_GRAPH_POINTS 12U -#define DASHBOARD_BUCKET_COUNT 12U -#define DASHBOARD_FACT_CAPACITY 4096U -#define DASHBOARD_ZONE_BUCKET_CAPACITY (MAX_BODY_ZONES + 1U) /* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */ @@ -2529,87 +2525,6 @@ typedef struct TrainlogSyncController { TrainlogAppSyncRun run; } TrainlogSyncController; -typedef struct TrainlogDashboardWeek { - int64_t start_day; - int64_t span_days; - char label[12]; - size_t sessions; - size_t maxima; - size_t working_improvements; - size_t max_improvements; -} TrainlogDashboardWeek; - -typedef struct TrainlogDashboardZoneBucket { - char label[TRAINLOG_NAME_MAX + 1U]; - size_t count; -} TrainlogDashboardZoneBucket; - -typedef enum TrainlogStatisticsPeriod { - TRAINLOG_STATS_30_DAYS = 0, - TRAINLOG_STATS_7_DAYS, - TRAINLOG_STATS_90_DAYS, - TRAINLOG_STATS_YEAR, - TRAINLOG_STATS_ALL -} TrainlogStatisticsPeriod; - -typedef struct TrainlogDashboardSnapshot { - bool error; - bool partial; - bool invalid_data; - bool has_performance; - TrainlogExercise exercise; - TrainlogLoadMode performance_load_mode; - TrainlogTrackingMode performance_tracking_mode; - int performance_dose; - char performance_equipment_id[TRAINLOG_ID_MAX + 1U]; - char performance_equipment_label[TRAINLOG_NAME_MAX + 1U]; - TrainlogExercisePerformancePoint performance[MAX_SESSIONS]; - size_t performance_count; - bool has_explicit_max; - TrainlogExercise max_exercise; - double max_weight_kg; - char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; - bool has_body; - size_t body_metric; - TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS]; - size_t body_count; - TrainlogDashboardWeek weeks[DASHBOARD_BUCKET_COUNT]; - size_t week_count; - size_t completed_session_count; - size_t performed_set_count; - size_t distinct_exercise_count; - size_t explicit_max_count; - /* WHY: this is a catalogue dashboard, not a history-derived muscle - * claim. CONTRACT: every active canonical exercise contributes once to - * its persisted primary zone, or to the explicit unclassified bucket. - * INVARIANT: aliases and secondary zones never affect these counts. */ - TrainlogDashboardZoneBucket zones[DASHBOARD_ZONE_BUCKET_CAPACITY]; - size_t zone_count; - TrainlogStatisticsPeriod period; -} TrainlogDashboardSnapshot; - -typedef enum TrainlogDashboardFactKind { - TRAINLOG_DASHBOARD_WORK, - TRAINLOG_DASHBOARD_MAX, - TRAINLOG_DASHBOARD_CONTINUOUS -} TrainlogDashboardFactKind; - -typedef struct TrainlogDashboardFact { - TrainlogDashboardFactKind kind; - TrainlogTimestampKey time; - char session_id[TRAINLOG_ID_MAX + 1U]; - char entry_id[TRAINLOG_ID_MAX + 1U]; - char exercise_id[TRAINLOG_ID_MAX + 1U]; - char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; - char equipment_id[TRAINLOG_ID_MAX + 1U]; - TrainlogTrackingMode tracking_mode; - TrainlogLoadMode load_mode; - size_t set_position; - int dose; - bool has_weight; - double weight_kg; -} TrainlogDashboardFact; - typedef struct TrainlogAppContext { TrainlogDatabase *database; TrainlogTerminal *terminal; @@ -3665,691 +3580,48 @@ static void app_shell_load_body_analytics(TrainlogAppContext *app) app->body_has_profile = body_analytics_profile_load(&app->body_profile); } -static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column, - char *output, size_t capacity) -{ - const unsigned char *value; - int bytes; - if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false; - value = sqlite3_column_text(statement, column); - bytes = sqlite3_column_bytes(statement, column); - if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false; - (void)memcpy(output, value, (size_t)bytes); - output[bytes] = '\0'; - return true; -} - -/* WHY: the global dashboard compares occurrence-owned facts, while the public - * performance reader deliberately collapses each session to one representative. - * CONTRACT: this private projection emits every performed set, every explicit - * MAX exactly once, and every continuous occurrence exactly once. INVARIANT: - * malformed legacy timestamps are omitted before sorting and reported through - * invalid_data; they can never reach a comparator with uninitialized keys. */ -static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database, - TrainlogDashboardFact *output, size_t capacity, size_t *output_count, - bool *truncated, bool *invalid_data) -{ - static const char *const SQL = - "SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id," - "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position," - "CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END," - "ps.weight_kg FROM performed_sets ps " - "JOIN session_exercises se ON se.id=ps.session_exercise_row_id " - "JOIN sessions s ON s.id=se.session_row_id " - "JOIN exercises e ON e.id=se.exercise_row_id " - "UNION ALL " - "SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id," - "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0," - "mr.max_weight_kg FROM max_results mr " - "JOIN session_exercises se ON se.id=mr.session_exercise_row_id " - "JOIN sessions s ON s.id=se.session_row_id " - "JOIN exercises e ON e.id=se.exercise_row_id " - "UNION ALL " - "SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id," - "COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0," - "ca.duration_seconds,NULL FROM continuous_activity ca " - "JOIN session_exercises se ON se.id=ca.session_exercise_row_id " - "JOIN sessions s ON s.id=se.session_row_id " - "JOIN exercises e ON e.id=se.exercise_row_id;"; - sqlite3_stmt *statement = NULL; - size_t count = 0U; - int rc; - if (output_count != NULL) *output_count = 0U; - if (truncated != NULL) *truncated = false; - if (invalid_data != NULL) *invalid_data = false; - if (database == NULL || database->connection == NULL || output_count == NULL || - truncated == NULL || invalid_data == 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) { - TrainlogDashboardFact fact; - const unsigned char *tracking; - const unsigned char *load; - int kind; - (void)memset(&fact, 0, sizeof(fact)); - if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER || - !dashboard_copy_sql_text(statement, 1, fact.started_at, - sizeof(fact.started_at)) || - !dashboard_copy_sql_text(statement, 2, fact.session_id, - sizeof(fact.session_id)) || - !dashboard_copy_sql_text(statement, 3, fact.entry_id, - sizeof(fact.entry_id)) || - !dashboard_copy_sql_text(statement, 4, fact.exercise_id, - sizeof(fact.exercise_id)) || - !dashboard_copy_sql_text(statement, 5, fact.equipment_id, - sizeof(fact.equipment_id)) || - sqlite3_column_type(statement, 6) != SQLITE_TEXT || - sqlite3_column_type(statement, 7) != SQLITE_TEXT || - !trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at), - &fact.time)) { - *invalid_data = true; - continue; - } - kind = sqlite3_column_int(statement, 0); - if (kind < 0 || kind > 2) { - *invalid_data = true; - continue; - } - fact.kind = (TrainlogDashboardFactKind)kind; - tracking = sqlite3_column_text(statement, 6); - load = sqlite3_column_text(statement, 7); - if (strcmp((const char *)tracking, "reps") == 0) - fact.tracking_mode = TRAINLOG_TRACKING_REPS; - else if (strcmp((const char *)tracking, "duration") == 0) - fact.tracking_mode = TRAINLOG_TRACKING_DURATION; - else { *invalid_data = true; continue; } - if (strcmp((const char *)load, "none") == 0) - fact.load_mode = TRAINLOG_LOAD_NONE; - else if (strcmp((const char *)load, "external") == 0) - fact.load_mode = TRAINLOG_LOAD_EXTERNAL; - else if (strcmp((const char *)load, "assistance") == 0) - fact.load_mode = TRAINLOG_LOAD_ASSISTANCE; - else { *invalid_data = true; continue; } - if (fact.kind == TRAINLOG_DASHBOARD_WORK) { - sqlite3_int64 position; - if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER || - sqlite3_column_type(statement, 9) != SQLITE_INTEGER) { - *invalid_data = true; continue; - } - position = sqlite3_column_int64(statement, 8); - if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) { - *invalid_data = true; continue; - } - fact.set_position = (size_t)position; - fact.dose = sqlite3_column_int(statement, 9); - } else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) { - if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) { - *invalid_data = true; continue; - } - fact.dose = sqlite3_column_int(statement, 9); - } - if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) { - int type = sqlite3_column_type(statement, 10); - if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) { - fact.has_weight = false; - } else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) || - !isfinite(sqlite3_column_double(statement, 10))) { - *invalid_data = true; continue; - } else { - fact.has_weight = true; - fact.weight_kg = sqlite3_column_double(statement, 10); - } - if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) { - *invalid_data = true; continue; - } - } - if (count == capacity) { *truncated = true; continue; } - output[count++] = fact; - } - if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) - return TRAINLOG_STATUS_DATABASE_ERROR; - *output_count = count; - return TRAINLOG_STATUS_OK; -} - -static int dashboard_fact_time_compare(const void *left, const void *right) -{ - const TrainlogDashboardFact *a = left; - const TrainlogDashboardFact *b = right; - int result = trainlog_timestamp_compare(&a->time, &b->time); - if (result != 0) return result; - result = strcmp(a->session_id, b->session_id); - if (result != 0) return result; - result = strcmp(a->entry_id, b->entry_id); - if (result != 0) return result; - if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1; - return a->set_position == b->set_position ? 0 : - (a->set_position < b->set_position ? -1 : 1); -} - -static int dashboard_performance_time_compare(const void *left, const void *right) -{ - const TrainlogExercisePerformancePoint *a = left; - const TrainlogExercisePerformancePoint *b = right; - TrainlogTimestampKey a_key; - TrainlogTimestampKey b_key; - (void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key); - (void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key); - { - int result = trainlog_timestamp_compare(&a_key, &b_key); - return result != 0 ? result : strcmp(a->session_id, b->session_id); - } -} - -static int dashboard_body_time_compare(const void *left, const void *right) -{ - const TrainlogBodyMetricPoint *a = left; - const TrainlogBodyMetricPoint *b = right; - TrainlogTimestampKey a_key; - TrainlogTimestampKey b_key; - (void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key); - (void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key); - return trainlog_timestamp_compare(&a_key, &b_key); -} - -static int64_t dashboard_floor_div(int64_t value, int64_t divisor) -{ - return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor); -} - -static int64_t dashboard_period_days(TrainlogStatisticsPeriod period) +static const char *dashboard_period_bucket_summary(TrainlogDashboardPeriod period) { switch (period) { - case TRAINLOG_STATS_7_DAYS: return 7; - case TRAINLOG_STATS_30_DAYS: return 30; - case TRAINLOG_STATS_90_DAYS: return 90; - case TRAINLOG_STATS_YEAR: return 365; - case TRAINLOG_STATS_ALL: return 0; - } - return 30; -} - -/* WHY: characterization tests must exercise rolling-window boundaries without - * depending on wall-clock execution time. CONTRACT: production always reads - * time(NULL); only the translation-unit test build may supply a fixed instant. - * INVARIANT: the override is absent from the installed binary and cannot alter - * persisted data or Dashboard semantics. */ -#ifdef TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST -static bool dashboard_characterization_clock_enabled; -static time_t dashboard_characterization_clock_value; -#endif - -static time_t dashboard_clock_now(void) -{ -#ifdef TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST - if (dashboard_characterization_clock_enabled) - return dashboard_characterization_clock_value; -#endif - return time(NULL); -} - -static bool dashboard_in_period(const TrainlogTimestampKey *key, - TrainlogStatisticsPeriod period) -{ - const int64_t days = dashboard_period_days(period); - /* Unix time and Trainlog's proleptic day zero differ by 719162 days. */ - const int64_t now = (int64_t)dashboard_clock_now() + - INT64_C(719162) * INT64_C(86400); - return key->utc_second <= now && - (days == 0 || key->utc_second >= now - days * INT64_C(86400)); -} - -/* WHY: the selected period must change both dashboard charts, while the - * canonical rolling membership test above remains the sole inclusion rule. - * CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day - * and roughly monthly (31-day) presentation buckets; Tout uses at most twelve - * equal day spans covering represented history. INVARIANT: every included - * local date is clamped into exactly one leading-to-current bucket. */ -static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard, - int64_t current_day, int64_t earliest_day) -{ - size_t count; - int64_t span; - size_t index; - switch (dashboard->period) { - case TRAINLOG_STATS_7_DAYS: count = 7U; span = 1; break; - case TRAINLOG_STATS_30_DAYS: count = 6U; span = 5; break; - case TRAINLOG_STATS_90_DAYS: count = 6U; span = 15; break; - case TRAINLOG_STATS_YEAR: count = 12U; span = 31; break; - case TRAINLOG_STATS_ALL: { - int64_t represented = current_day >= earliest_day - ? current_day - earliest_day + 1 : 1; - count = represented < (int64_t)DASHBOARD_BUCKET_COUNT - ? (size_t)represented : DASHBOARD_BUCKET_COUNT; - if (count == 0U) count = 1U; - span = (represented + (int64_t)count - 1) / (int64_t)count; - break; - } - } - dashboard->week_count = count; - for (index = 0U; index < count; ++index) { - int64_t distance = (int64_t)(count - 1U - index) * span; - dashboard->weeks[index].start_day = current_day - distance - span + 1; - dashboard->weeks[index].span_days = span; - if (index + 1U == count) - (void)snprintf(dashboard->weeks[index].label, - sizeof(dashboard->weeks[index].label), "%s", "actuel"); - else if (dashboard->period == TRAINLOG_STATS_7_DAYS) - (void)snprintf(dashboard->weeks[index].label, - sizeof(dashboard->weeks[index].label), "J−%zu", count - 1U - index); - else if (dashboard->period == TRAINLOG_STATS_YEAR) - (void)snprintf(dashboard->weeks[index].label, - sizeof(dashboard->weeks[index].label), "M−%zu", count - 1U - index); - else - (void)snprintf(dashboard->weeks[index].label, - sizeof(dashboard->weeks[index].label), "P−%zu", count - 1U - index); - } -} - -static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard, - int64_t local_day) -{ - int64_t first; - int64_t span; - size_t slot; - if (dashboard->week_count == 0U) return 0U; - first = dashboard->weeks[0].start_day; - span = dashboard->weeks[0].span_days; - if (local_day <= first) return 0U; - slot = (size_t)((local_day - first) / span); - return slot < dashboard->week_count ? slot : dashboard->week_count - 1U; -} - -static const char *dashboard_period_bucket_summary(TrainlogStatisticsPeriod period) -{ - switch (period) { - case TRAINLOG_STATS_7_DAYS: return "7 jours · quotidien"; - case TRAINLOG_STATS_30_DAYS: return "30 jours · pas de 5 j"; - case TRAINLOG_STATS_90_DAYS: return "90 jours · pas de 15 j"; - case TRAINLOG_STATS_YEAR: return "1 an · pas de 31 j"; - case TRAINLOG_STATS_ALL: return "Tout · 12 périodes max."; + case TRAINLOG_DASHBOARD_7_DAYS: return "7 jours · quotidien"; + case TRAINLOG_DASHBOARD_30_DAYS: return "30 jours · pas de 5 j"; + case TRAINLOG_DASHBOARD_90_DAYS: return "90 jours · pas de 15 j"; + case TRAINLOG_DASHBOARD_YEAR: return "1 an · pas de 31 j"; + case TRAINLOG_DASHBOARD_ALL: return "Tout · 12 périodes max."; } return "Période"; } -static bool dashboard_fact_external_context_qualifies(TrainlogAppContext *app, - const TrainlogDashboardFact *fact) +static void dashboard_period_bucket_label(TrainlogDashboardPeriod period, + size_t index, size_t count, char output[12]) { - TrainlogResolvedEquipment equipment; - if (fact->equipment_id[0] == '\0' || - trainlog_database_resolve_equipment(app->database, fact->equipment_id, - &equipment) != TRAINLOG_STATUS_OK) return false; - return strcmp(equipment.load_semantics, "external") == 0; -} - -static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises, - size_t count, const char *exercise_id) -{ - size_t index; - for (index = 0U; index < count; ++index) - if (strcmp(exercises[index].exercise_id, exercise_id) == 0) - return &exercises[index]; - return NULL; -} - -static int dashboard_zone_bucket_compare(const void *left, const void *right) -{ - const TrainlogDashboardZoneBucket *a = left; - const TrainlogDashboardZoneBucket *b = right; - if (a->count != b->count) return a->count > b->count ? -1 : 1; - return strcmp(a->label, b->label); -} - -/* WHY: a body-zone dashboard must describe the current catalogue rather than - * infer classifications from history or commercial names. CONTRACT: one - * canonical exercise is counted once, using only its persisted primary body - * zone. INVARIANT: missing primary relations remain visible as "Non classés"; - * secondary relations and aliases can neither create nor duplicate a bucket. */ -static bool dashboard_load_zone_distribution(TrainlogAppContext *app, - const TrainlogExercise *exercises, size_t exercise_count) -{ - TrainlogDashboardSnapshot *dashboard = &app->dashboard; - size_t exercise_index; - for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) { - TrainlogExerciseBodyZone relations[MAX_BODY_ZONES]; - const char *label = "Non classés"; - size_t relation_count = 0U; - size_t relation_index; - size_t bucket; - if (trainlog_database_list_exercise_body_zones(app->database, - exercises[exercise_index].exercise_id, relations, MAX_BODY_ZONES, - &relation_count) != TRAINLOG_STATUS_OK) return false; - for (relation_index = 0U; relation_index < relation_count; ++relation_index) { - if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup( - relations[relation_index].zone_id); - if (zone != NULL && !zone->is_group) label = zone->display_name; - break; - } - } - for (bucket = 0U; bucket < dashboard->zone_count; ++bucket) - if (strcmp(dashboard->zones[bucket].label, label) == 0) break; - if (bucket == dashboard->zone_count) { - if (bucket == DASHBOARD_ZONE_BUCKET_CAPACITY) return false; - (void)snprintf(dashboard->zones[bucket].label, - sizeof(dashboard->zones[bucket].label), "%s", label); - ++dashboard->zone_count; - } - ++dashboard->zones[bucket].count; + if (index + 1U == count) { + (void)snprintf(output, 12U, "%s", + trainlog_presentation_text("dashboard.current")); + } else if (period == TRAINLOG_DASHBOARD_7_DAYS) { + (void)snprintf(output, 12U, "J−%zu", count - 1U - index); + } else if (period == TRAINLOG_DASHBOARD_YEAR) { + (void)snprintf(output, 12U, "M−%zu", count - 1U - index); + } else { + (void)snprintf(output, 12U, "P−%zu", count - 1U - index); } - qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]), - dashboard_zone_bucket_compare); - return true; } -/* WHY: the landing page must remain useful without rendering-time SQL. - * CONTRACT: this bounded controller snapshot contains only actual persisted - * facts. One working-performance series has an exact exercise/equipment/load - * identity; explicit MAX is separate, and assistance/planned targets never - * enter it. All chronology is decided by Trainlog's timestamp parser, not - * SQLite text ordering. */ +/* The TUI owns only the reference clock and selected presentation period. */ static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app) { - TrainlogDashboardSnapshot *dashboard = &app->dashboard; - TrainlogStatisticsPeriod period = dashboard->period; - TrainlogExercise *exercises = NULL; - TrainlogDashboardFact *facts = NULL; - TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS]; - TrainlogSessionSummary sessions[MAX_SESSIONS]; - TrainlogTimestampKey newest_performance; - char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = ""; - size_t exercise_count = 0U; - size_t fact_count = 0U; - size_t session_count = 0U; - size_t exercise_index; - int64_t current_day; - int64_t earliest_day = INT64_MAX; - time_t now; - bool has_newest_performance = false; - bool facts_truncated = false; - bool facts_invalid = false; - - (void)memset(dashboard, 0, sizeof(*dashboard)); - dashboard->period = period; - facts = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*facts)); - if (facts == NULL) { - dashboard->error = true; - return; - } - if (trainlog_database_read_snapshot_begin(app->database) != TRAINLOG_STATUS_OK) { - dashboard->error = true; - free(facts); - return; - } - if (trainlog_database_list_sessions(app->database, sessions, MAX_SESSIONS, - &session_count) != TRAINLOG_STATUS_OK) { - dashboard->error = true; - goto finish; - } - dashboard->partial = session_count == MAX_SESSIONS; - /* CONTRACT: frequency is one count per actual session; a MAX marker is an - * attribute of that count and never a second session. Rolling membership - * uses canonical instants, while buckets use represented local dates. - * Presentation always ends at the canonical current day, so the bucket - * containing that day remains explicit even when it is zero. */ - now = dashboard_clock_now(); + TrainlogDashboardQuery query; + TrainlogDashboardPeriod period = app->dashboard.period; + time_t now = time(NULL); + query.period = period; if (now == (time_t)-1) { - dashboard->error = true; - goto finish; + (void)memset(&app->dashboard, 0, sizeof(app->dashboard)); + app->dashboard.period = period; + app->dashboard.error = true; + return; } - current_day = dashboard_floor_div((int64_t)now + - INT64_C(719162) * INT64_C(86400), INT64_C(86400)); - for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) { - TrainlogTimestampKey key; - if (!trainlog_timestamp_parse(sessions[exercise_index].started_at, - strlen(sessions[exercise_index].started_at), &key)) { - dashboard->invalid_data = true; - continue; - } - if (!dashboard_in_period(&key, dashboard->period)) continue; - ++dashboard->completed_session_count; - if (key.local_day < earliest_day) earliest_day = key.local_day; - } - dashboard_prepare_period_buckets(dashboard, current_day, - earliest_day == INT64_MAX ? current_day : earliest_day); - for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) { - TrainlogTimestampKey key; - size_t slot; - if (!trainlog_timestamp_parse(sessions[exercise_index].started_at, - strlen(sessions[exercise_index].started_at), &key) || - !dashboard_in_period(&key, dashboard->period)) continue; - slot = dashboard_period_bucket(dashboard, key.local_day); - ++dashboard->weeks[slot].sessions; - if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST) - ++dashboard->weeks[slot].maxima; - } - if (trainlog_database_list_exercises(app->database, NULL, 0U, - &exercise_count) != TRAINLOG_STATUS_OK || - exercise_count > SIZE_MAX / sizeof(*exercises)) { - dashboard->error = true; - goto finish; - } - if (exercise_count > 0U) { - exercises = calloc(exercise_count, sizeof(*exercises)); - if (exercises == NULL || trainlog_database_list_exercises(app->database, - exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) { - dashboard->error = true; - goto finish; - } - } - if (!dashboard_load_zone_distribution(app, exercises, exercise_count)) { - dashboard->error = true; - goto finish; - } - if (dashboard_database_list_facts(app->database, facts, - DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated, - &facts_invalid) != TRAINLOG_STATUS_OK) { - dashboard->error = true; - goto finish; - } - dashboard->partial = dashboard->partial || facts_truncated; - dashboard->invalid_data = dashboard->invalid_data || facts_invalid; - qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare); - for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) { - TrainlogDashboardFact *fact = &facts[exercise_index]; - TrainlogExercise *exercise; - bool external; - bool comparable_work; - bool comparable_max; - size_t prior; - bool has_prior = false; - double prior_best = 0.0; - exercise = dashboard_find_exercise(exercises, exercise_count, - fact->exercise_id); - if (!dashboard_in_period(&fact->time, dashboard->period)) continue; - if (fact->kind == TRAINLOG_DASHBOARD_WORK) - ++dashboard->performed_set_count; - else if (fact->kind == TRAINLOG_DASHBOARD_MAX) - ++dashboard->explicit_max_count; - { - size_t seen; - for (seen = 0U; seen < exercise_index; ++seen) - if (dashboard_in_period(&facts[seen].time, dashboard->period) && - strcmp(facts[seen].exercise_id, fact->exercise_id) == 0) - break; - if (seen == exercise_index) ++dashboard->distinct_exercise_count; - } - external = dashboard_fact_external_context_qualifies(app, fact); - comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external && - fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 && - fact->has_weight && isfinite(fact->weight_kg) && - fact->weight_kg >= 0.0; - comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external && - fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0; - - /* CONTRACT: Android and TUI compare the same actual observations: - * exact exercise + nonempty resolved external equipment + external - * load semantics + exact performed dose. Only facts at a strictly - * earlier canonical instant establish the prior best; bytewise IDs - * order equal-instant presentation but never manufacture an event. */ - if (comparable_work || comparable_max) { - for (prior = 0U; prior < exercise_index; ++prior) { - TrainlogDashboardFact *old = &facts[prior]; - bool same = old->kind == fact->kind && - strcmp(old->exercise_id, fact->exercise_id) == 0 && - strcmp(old->equipment_id, fact->equipment_id) == 0 && - old->tracking_mode == fact->tracking_mode && - old->load_mode == fact->load_mode && - (fact->kind != TRAINLOG_DASHBOARD_WORK || - old->dose == fact->dose); - if (!same || trainlog_timestamp_compare(&old->time, - &fact->time) >= 0) continue; - if (!has_prior || old->weight_kg > prior_best) - prior_best = old->weight_kg; - has_prior = true; - } - if (has_prior && fact->weight_kg > prior_best && - dashboard->week_count > 0U) { - size_t slot = dashboard_period_bucket(dashboard, - fact->time.local_day); - if (comparable_work) - ++dashboard->weeks[slot].working_improvements; - else ++dashboard->weeks[slot].max_improvements; - } - } - if (comparable_max && exercise != NULL) { - TrainlogTimestampKey current_max; - int order = 1; - if (dashboard->has_explicit_max && - trainlog_timestamp_parse(dashboard->max_timestamp, - strlen(dashboard->max_timestamp), ¤t_max)) - order = trainlog_timestamp_compare(&fact->time, ¤t_max); - if (!dashboard->has_explicit_max || order > 0 || - (order == 0 && strcmp(fact->exercise_id, - dashboard->max_exercise.exercise_id) < 0)) { - dashboard->has_explicit_max = true; - dashboard->max_exercise = *exercise; - dashboard->max_weight_kg = fact->weight_kg; - (void)snprintf(dashboard->max_timestamp, - sizeof(dashboard->max_timestamp), "%s", fact->started_at); - } - } - if (comparable_work && exercise != NULL) { - int order = has_newest_performance ? - trainlog_timestamp_compare(&fact->time, &newest_performance) : 1; - if (!has_newest_performance || order > 0 || - (order == 0 && strcmp(fact->exercise_id, - dashboard->exercise.exercise_id) < 0)) { - dashboard->has_performance = true; - dashboard->exercise = *exercise; - dashboard->performance_load_mode = fact->load_mode; - dashboard->performance_tracking_mode = fact->tracking_mode; - dashboard->performance_dose = fact->dose; - (void)snprintf(dashboard->performance_equipment_id, - sizeof(dashboard->performance_equipment_id), "%s", - fact->equipment_id); - newest_performance = fact->time; - has_newest_performance = true; - } - } - } - if (dashboard->has_performance) { - TrainlogResolvedEquipment equipment; - size_t index; - if (trainlog_database_resolve_equipment(app->database, - dashboard->performance_equipment_id, &equipment) == - TRAINLOG_STATUS_OK) - (void)snprintf(dashboard->performance_equipment_label, - sizeof(dashboard->performance_equipment_label), "%s", - equipment.display_name); - for (index = 0U; index < fact_count && - dashboard->performance_count < MAX_SESSIONS; ++index) { - TrainlogDashboardFact *fact = &facts[index]; - TrainlogExercisePerformancePoint *point; - if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 || - fact->load_mode != dashboard->performance_load_mode || - fact->tracking_mode != dashboard->performance_tracking_mode || - fact->dose != dashboard->performance_dose || - strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 || - strcmp(fact->equipment_id, - dashboard->performance_equipment_id) != 0 || - !dashboard_in_period(&fact->time, dashboard->period)) continue; - point = &dashboard->performance[dashboard->performance_count++]; - (void)memset(point, 0, sizeof(*point)); - (void)snprintf(point->session_id, sizeof(point->session_id), "%s", - fact->session_id); - (void)snprintf(point->started_at, sizeof(point->started_at), "%s", - fact->started_at); - point->tracking_mode = fact->tracking_mode; - point->load_mode = fact->load_mode; - point->actual_set_count = 1U; - point->has_performance = 1; - point->metric_value = fact->dose; - point->has_weight = 1; - point->weight_kg = fact->weight_kg; - (void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s", - fact->equipment_id); - } - if (dashboard->performance_count == MAX_SESSIONS) - dashboard->partial = true; - qsort(dashboard->performance, dashboard->performance_count, - sizeof(dashboard->performance[0]), dashboard_performance_time_compare); - } - - for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) { - size_t count = 0U; - size_t point_index; - if (trainlog_database_list_body_metric_points(app->database, - BODY_METRICS[exercise_index].metric, body, - MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) { - dashboard->error = true; - continue; - } - if (count == MAX_BODY_METRIC_POINTS) dashboard->partial = true; - for (point_index = 0U; point_index < count; ++point_index) { - TrainlogTimestampKey candidate; - TrainlogTimestampKey current; - int order = 1; - if (!trainlog_timestamp_parse(body[point_index].observed_at, - strlen(body[point_index].observed_at), &candidate)) { - dashboard->invalid_data = true; - continue; - } - if (!dashboard_in_period(&candidate, dashboard->period)) continue; - if (newest_body[0] != '\0') { - (void)trainlog_timestamp_parse(newest_body, strlen(newest_body), ¤t); - order = trainlog_timestamp_compare(&candidate, ¤t); - } - if (newest_body[0] == '\0' || order > 0 || - (order == 0 && exercise_index < dashboard->body_metric)) { - dashboard->has_body = true; - dashboard->body_metric = exercise_index; - (void)snprintf(newest_body, sizeof(newest_body), "%s", - body[point_index].observed_at); - } - } - } - if (dashboard->has_body) { - size_t count = 0U; - size_t point_index; - if (trainlog_database_list_body_metric_points(app->database, - BODY_METRICS[dashboard->body_metric].metric, body, - MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) { - dashboard->error = true; - goto finish; - } - if (count == MAX_BODY_METRIC_POINTS) - dashboard->partial = true; - for (point_index = 0U; point_index < count; ++point_index) { - TrainlogTimestampKey key; - if (!trainlog_timestamp_parse(body[point_index].observed_at, - strlen(body[point_index].observed_at), &key)) { - dashboard->invalid_data = true; - continue; - } - if (dashboard_in_period(&key, dashboard->period)) - dashboard->body[dashboard->body_count++] = body[point_index]; - } - qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]), - dashboard_body_time_compare); - } - -finish: - if (trainlog_database_read_snapshot_end(app->database, !dashboard->error) != - TRAINLOG_STATUS_OK) dashboard->error = true; - free(exercises); - free(facts); + query.reference_unix_second = (int64_t)now; + (void)trainlog_dashboard_load(app->database, &query, &app->dashboard); } static void sync_controller_load_history(TrainlogSyncController *sync) @@ -6442,6 +5714,11 @@ static void app_shell_dashboard_zone_chart(TrainlogAppContext *app, if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows; } for (index = 0U; index < bucket_rows; ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup( + dashboard->zones[index].zone_id); + const char *label = dashboard->zones[index].zone_id[0] == '\0' + ? trainlog_presentation_text("ui.unclassified") + : (zone == NULL ? dashboard->zones[index].zone_id : zone->display_name); int row = top + (int)index * row_stride; int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count * (size_t)bar_width + maximum - 1U) / maximum); @@ -6450,7 +5727,7 @@ static void app_shell_dashboard_zone_chart(TrainlogAppContext *app, * independently scaled partial-height rows can never form the former * solid staircase. Wide layouts leave a blank row between buckets. */ trainlog_surface_printf(app->content, row, left, - "%-*.*s", label_width, label_width, dashboard->zones[index].label); + "%-*.*s", label_width, label_width, label); trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); for (block = 0; block < blocks; ++block) @@ -6496,9 +5773,9 @@ static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app, dashboard->weeks[dashboard->week_count - 1U].maxima); baseline = top + height; for (index = 0U; index < dashboard->week_count; ++index) { - const char *label = index + 1U == dashboard->week_count - ? trainlog_presentation_text("dashboard.current") - : dashboard->weeks[index].label; + char label[12]; + dashboard_period_bucket_label(dashboard->period, index, + dashboard->week_count, label); int label_margin = 3; int x = dashboard->week_count > 1U ? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) / @@ -6546,12 +5823,12 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex body_values[index] = dashboard->body[index].value; trainlog_surface_printf(app->content, 2, 2, "Période %s %s %s %s %s", - dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j", - dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j", - dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j", - dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an", + dashboard->period == TRAINLOG_DASHBOARD_7_DAYS ? "[7j]" : "7j", + dashboard->period == TRAINLOG_DASHBOARD_30_DAYS ? "[30j]" : "30j", + dashboard->period == TRAINLOG_DASHBOARD_90_DAYS ? "[90j]" : "90j", + dashboard->period == TRAINLOG_DASHBOARD_YEAR ? "[1an]" : "1an", trainlog_presentation_source( - dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout")); + dashboard->period == TRAINLOG_DASHBOARD_ALL ? "[Tout]" : "Tout")); trainlog_surface_printf(app->content, 3, 2, "Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu", dashboard->completed_session_count, dashboard->performed_set_count, diff --git a/tui/tests/test_dashboard.c b/tui/tests/test_dashboard.c new file mode 100644 index 0000000..015e46f --- /dev/null +++ b/tui/tests/test_dashboard.c @@ -0,0 +1,514 @@ +/** @file test_dashboard.c @brief Core Dashboard characterization parity. */ +#include +#include +#include +#include +#include + +#include "trainlog/dashboard.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 dashboard_load(TrainlogDatabase *database, + TrainlogDashboardPeriod period, TrainlogDashboardSnapshot *snapshot) +{ + const TrainlogDashboardQuery query = {period, INT64_C(1789560000)}; + return trainlog_dashboard_load(database, &query, snapshot) == + TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_completed_set_dose(TrainlogDatabase *database, + const char *session_id, const char *started_at, const char *ended_at, + TrainlogLoadMode load_mode, const char *equipment_id, double weight, int dose) +{ + TrainlogSetInput set = {dose, 0, true, weight}; + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + equipment_id); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.load_mode = load_mode; + occurrence.rest_seconds = 60; + occurrence.target_sets = 2; + occurrence.target_reps = 10; + occurrence.target_has_weight = true; + occurrence.target_weight_kg = 999.0; + occurrence.sets = &set; + occurrence.set_count = 1U; + (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.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_completed_set(TrainlogDatabase *database, + const char *session_id, const char *started_at, const char *ended_at, + TrainlogLoadMode load_mode, const char *equipment_id, double weight) +{ + return dashboard_insert_completed_set_dose(database, session_id, started_at, + ended_at, load_mode, equipment_id, weight, 8); +} +static bool dashboard_insert_max_for_exercise(TrainlogDatabase *database, + const char *session_id, const char *entry_id, const char *exercise_id, + const char *started_at, double weight) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + exercise_id); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_NONE; + occurrence.has_max_weight = true; + occurrence.max_weight_kg = weight; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + session.session_type = TRAINLOG_SESSION_MAX_TEST; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_plan_for_exercise(TrainlogDatabase *database, + const char *session_id, const char *exercise_id, const char *started_at) +{ + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + exercise_id); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrence.target_sets = 3; + occurrence.target_reps = 10; + occurrence.target_has_weight = true; + occurrence.target_weight_kg = 50.0; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool dashboard_insert_optional_weight_set(TrainlogDatabase *database, + const char *session_id, const char *exercise_id, const char *started_at, + bool has_weight, double weight) +{ + TrainlogSetInput set = {10, 0, has_weight, weight}; + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + exercise_id); + (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", + "leg_press"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrence.target_sets = 1; + occurrence.target_reps = 10; + occurrence.target_has_weight = true; + occurrence.target_weight_kg = 25.0; + occurrence.sets = &set; + occurrence.set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool test_dashboard_characterization_empty_and_window_boundaries(void) +{ + TrainlogDatabase *database = NULL; + TrainlogDashboardSnapshot dashboard; + size_t index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&dashboard, 0, sizeof(dashboard)); + dashboard.period = TRAINLOG_DASHBOARD_30_DAYS; + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && !dashboard.partial && + !dashboard.invalid_data); + CHECK(!dashboard.has_performance && !dashboard.has_explicit_max && + !dashboard.has_body); + CHECK(dashboard.performance_count == 0U && dashboard.body_count == 0U); + CHECK(dashboard.completed_session_count == 0U && + dashboard.performed_set_count == 0U && + dashboard.distinct_exercise_count == 0U && + dashboard.explicit_max_count == 0U); + /* The database is empty of training history, not of the seeded canonical + * catalogue: zone distribution is therefore deliberately non-empty. */ + CHECK(dashboard.zone_count == 4U); + CHECK(dashboard.zones[0].zone_id[0] == '\0' && + dashboard.zones[0].count == 2U); + CHECK(dashboard.week_count == 6U); + for (index = 0U; index < dashboard.week_count; ++index) + CHECK(dashboard.weeks[index].sessions == 0U && + dashboard.weeks[index].maxima == 0U && + dashboard.weeks[index].working_improvements == 0U && + dashboard.weeks[index].max_improvements == 0U); + + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Frontière", + "frontiere", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set(database, + "se_31000000-0000-4000-8000-000000000001", + "2026-08-17T11:59:59Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0)); + CHECK(dashboard_insert_completed_set(database, + "se_31000000-0000-4000-8000-000000000002", + "2026-08-17T12:00:00Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); + CHECK(dashboard_insert_completed_set(database, + "se_31000000-0000-4000-8000-000000000003", + "2026-08-17T12:00:01Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 30.0)); + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && !dashboard.invalid_data); + CHECK(dashboard.completed_session_count == 2U); + CHECK(dashboard.performed_set_count == 2U); + CHECK(dashboard.distinct_exercise_count == 1U); + CHECK(dashboard.has_performance && dashboard.performance_count == 2U); + CHECK(strcmp(dashboard.performance[0].started_at, + "2026-08-17T12:00:00Z") == 0); + CHECK(strcmp(dashboard.performance[1].started_at, + "2026-08-17T12:00:01Z") == 0); + trainlog_database_close(database); + return true; +} + +static bool test_dashboard_characterization_modalities_ties_and_zones(void) +{ + static const char *const primary_secondary[] = {"back"}; + TrainlogDatabase *database = NULL; + TrainlogDashboardSnapshot dashboard; + TrainlogSetInput rep_sets[] = { + {10, 0, true, 40.0}, {8, 0, true, 1000.0} + }; + TrainlogSetInput repeated_set = {12, 0, true, 50.0}; + TrainlogSetInput duration_set = {0, 60, true, 30.0}; + TrainlogSetInput assistance_set = {10, 0, true, 5.0}; + TrainlogSetInput unweighted_set = {10, 0, false, 0.0}; + TrainlogSessionExerciseInput occurrences[4]; + TrainlogSessionInput session; + bool chest = false; + bool back = false; + bool unclassified = false; + size_t working = 0U; + size_t index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000000", "MAX égalité", "max egalite", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Répétitions", + "repetitions", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000001", "MAX secondaire", + "max secondaire", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000002", "Durée séries", "duree series", + TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000003", "Continu", "continu", + TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000004", "Assistance", "assistance", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_10000000-0000-4000-8000-000000000005", "Poids absent antérieur", + "poids absent anterieur", TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, (TrainlogExerciseDataFields)0) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_33333333-3333-4333-8333-333333333333", "chest", + primary_secondary, 1U) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000002", "back", NULL, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, + "ex_10000000-0000-4000-8000-000000000004", "chest", NULL, 0U) == + TRAINLOG_STATUS_OK); + + (void)memset(occurrences, 0, sizeof(occurrences)); + (void)memset(&session, 0, sizeof(session)); + for (index = 0U; index < 2U; ++index) { + (void)snprintf(occurrences[index].exercise_id, + sizeof(occurrences[index].exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrences[index].equipment_id, + sizeof(occurrences[index].equipment_id), "%s", "leg_press"); + occurrences[index].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[index].tracking_mode = TRAINLOG_TRACKING_REPS; + occurrences[index].load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrences[index].target_sets = 1; + occurrences[index].target_reps = 10; + occurrences[index].target_has_weight = true; + occurrences[index].target_weight_kg = 40.0; + } + (void)snprintf(occurrences[0].entry_id, sizeof(occurrences[0].entry_id), "%s", + "sxe_10000000-0000-4000-8000-000000000001"); + occurrences[0].sets = rep_sets; + occurrences[0].set_count = 2U; + (void)snprintf(occurrences[1].entry_id, sizeof(occurrences[1].entry_id), "%s", + "sxe_10000000-0000-4000-8000-000000000002"); + occurrences[1].sets = &repeated_set; + occurrences[1].set_count = 1U; + (void)snprintf(occurrences[2].exercise_id, sizeof(occurrences[2].exercise_id), + "%s", "ex_10000000-0000-4000-8000-000000000002"); + (void)snprintf(occurrences[2].equipment_id, sizeof(occurrences[2].equipment_id), + "%s", "leg_press"); + occurrences[2].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[2].tracking_mode = TRAINLOG_TRACKING_DURATION; + occurrences[2].load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrences[2].target_sets = 1; + occurrences[2].target_duration_seconds = 60; + occurrences[2].target_has_weight = true; + occurrences[2].target_weight_kg = 30.0; + occurrences[2].sets = &duration_set; + occurrences[2].set_count = 1U; + (void)snprintf(occurrences[3].exercise_id, sizeof(occurrences[3].exercise_id), + "%s", "ex_10000000-0000-4000-8000-000000000003"); + occurrences[3].recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + occurrences[3].tracking_mode = TRAINLOG_TRACKING_DURATION; + occurrences[3].load_mode = TRAINLOG_LOAD_NONE; + occurrences[3].continuous_duration_seconds = 600; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_32000000-0000-4000-8000-000000000001"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-10T08:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = occurrences; + session.exercise_count = 4U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_optional_weight_set(database, + "se_32000000-0000-4000-8000-000000000009", + "ex_10000000-0000-4000-8000-000000000005", "2026-09-08T08:00:00Z", + false, 0.0)); + CHECK(dashboard_insert_optional_weight_set(database, + "se_32000000-0000-4000-8000-000000000010", + "ex_10000000-0000-4000-8000-000000000005", "2026-09-09T08:00:00Z", + true, 25.0)); + + (void)memset(&occurrences[0], 0, sizeof(occurrences[0])); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id), + "%s", "ex_10000000-0000-4000-8000-000000000004"); + (void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id), + "%s", "assisted_dip_chin_machine"); + occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS; + occurrences[0].load_mode = TRAINLOG_LOAD_ASSISTANCE; + occurrences[0].target_sets = 1; + occurrences[0].target_reps = 10; + occurrences[0].target_has_weight = true; + occurrences[0].target_weight_kg = 5.0; + occurrences[0].sets = &assistance_set; + occurrences[0].set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_32000000-0000-4000-8000-000000000002"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-11T08:00:00Z"); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", + "2026-09-11T09:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = occurrences; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_plan_for_exercise(database, + "se_32000000-0000-4000-8000-000000000003", + "ex_10000000-0000-4000-8000-000000000001", "2026-09-12T08:00:00Z")); + (void)memset(&occurrences[0], 0, sizeof(occurrences[0])); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id), + "%s", "ex_33333333-3333-4333-8333-333333333333"); + (void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id), + "%s", "leg_press"); + occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS; + occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS; + occurrences[0].load_mode = TRAINLOG_LOAD_EXTERNAL; + occurrences[0].target_sets = 1; + occurrences[0].target_reps = 10; + occurrences[0].target_has_weight = true; + occurrences[0].target_weight_kg = 40.0; + occurrences[0].sets = &unweighted_set; + occurrences[0].set_count = 1U; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_32000000-0000-4000-8000-000000000008"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-12T12:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = occurrences; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + CHECK(dashboard_insert_completed_set_dose(database, + "se_32000000-0000-4000-8000-000000000004", "2026-09-13T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 45.0, 10)); + CHECK(dashboard_insert_completed_set_dose(database, + "se_32000000-0000-4000-8000-000000000005", "2026-09-13T08:00:00Z", + "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 50.0, 10)); + CHECK(dashboard_insert_max_for_exercise(database, + "se_32000000-0000-4000-8000-000000000006", + "sxe_32000000-0000-4000-8000-000000000006", + "ex_10000000-0000-4000-8000-000000000001", "2026-09-14T08:00:00Z", 100.0)); + CHECK(dashboard_insert_max_for_exercise(database, + "se_32000000-0000-4000-8000-000000000007", + "sxe_32000000-0000-4000-8000-000000000007", + "ex_10000000-0000-4000-8000-000000000000", "2026-09-14T08:00:00Z", 200.0)); + + (void)memset(&dashboard, 0, sizeof(dashboard)); + dashboard.period = TRAINLOG_DASHBOARD_30_DAYS; + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && !dashboard.partial && + !dashboard.invalid_data); + CHECK(dashboard.completed_session_count == 9U); + CHECK(dashboard.performed_set_count == 10U); + CHECK(dashboard.explicit_max_count == 2U); + CHECK(dashboard.distinct_exercise_count == 7U); + CHECK(dashboard.has_performance); + CHECK(strcmp(dashboard.exercise.exercise_id, + "ex_33333333-3333-4333-8333-333333333333") == 0); + CHECK(dashboard.performance_tracking_mode == TRAINLOG_TRACKING_REPS && + dashboard.performance_dose == 10); + CHECK(dashboard.performance_count == 4U); + CHECK(dashboard.performance[0].weight_kg == 40.0 && + dashboard.performance[1].has_weight == 1 && + dashboard.performance[1].weight_kg == 0.0 && + dashboard.performance[2].weight_kg == 45.0 && + dashboard.performance[3].weight_kg == 50.0); + CHECK(dashboard.has_explicit_max && dashboard.max_weight_kg == 200.0); + CHECK(strcmp(dashboard.max_exercise.exercise_id, + "ex_10000000-0000-4000-8000-000000000000") == 0); + for (index = 0U; index < dashboard.week_count; ++index) + working += dashboard.weeks[index].working_improvements; + /* Existing behavior: a matching earlier unweighted set supplies a 0.0 + * baseline and therefore makes the later weighted set an improvement. */ + CHECK(working == 3U); + CHECK(dashboard.zone_count == 4U); + for (index = 0U; index < dashboard.zone_count; ++index) { + chest = chest || (strcmp(dashboard.zones[index].zone_id, "chest") == 0 && + dashboard.zones[index].count == 3U); + back = back || (strcmp(dashboard.zones[index].zone_id, "back") == 0 && + dashboard.zones[index].count == 2U); + unclassified = unclassified || + (dashboard.zones[index].zone_id[0] == '\0' && + dashboard.zones[index].count == 6U); + } + CHECK(chest && back && unclassified); + trainlog_database_close(database); + return true; +} + +static bool test_dashboard_characterization_session_and_fact_bounds(void) +{ + TrainlogDatabase *database = NULL; + TrainlogDashboardSnapshot dashboard; + TrainlogSetInput *sets = NULL; + TrainlogSessionExerciseInput occurrence; + TrainlogSessionInput session; + size_t index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Bornes", "bornes", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + for (index = 0U; index < TRAINLOG_DASHBOARD_SESSION_CAPACITY; ++index) { + char session_id[TRAINLOG_ID_MAX + 1U]; + (void)snprintf(session_id, sizeof(session_id), + "se_33000000-0000-4000-8000-%012zu", index); + CHECK(dashboard_insert_completed_set(database, session_id, + "2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); + } + (void)memset(&dashboard, 0, sizeof(dashboard)); + dashboard.period = TRAINLOG_DASHBOARD_ALL; + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && dashboard.partial); + CHECK(dashboard.completed_session_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY); + CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY); + CHECK(dashboard_insert_completed_set(database, + "se_33000000-0000-4000-8000-000000000128", + "2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && dashboard.partial); + CHECK(dashboard.completed_session_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY); + CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY + 1U); + trainlog_database_close(database); + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_33333333-3333-4333-8333-333333333333", "Faits bornés", + "faits bornes", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + sets = calloc(TRAINLOG_DASHBOARD_FACT_CAPACITY, sizeof(*sets)); + CHECK(sets != NULL); + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)memset(&session, 0, sizeof(session)); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", + "ex_33333333-3333-4333-8333-333333333333"); + occurrence.recording_mode = TRAINLOG_RECORDING_SETS; + occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; + occurrence.load_mode = TRAINLOG_LOAD_NONE; + occurrence.target_sets = 1; + occurrence.target_reps = 1; + occurrence.sets = sets; + occurrence.set_count = TRAINLOG_DASHBOARD_FACT_CAPACITY; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", + "se_34000000-0000-4000-8000-000000000001"); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", + "2026-09-10T08:00:00Z"); + session.session_type = TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + free(sets); + sets = NULL; + (void)memset(&dashboard, 0, sizeof(dashboard)); + dashboard.period = TRAINLOG_DASHBOARD_ALL; + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && !dashboard.partial); + CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_FACT_CAPACITY); + /* A second honest persisted occurrence crosses the fact capacity without + * bypassing the public persistence contract. */ + CHECK(dashboard_insert_completed_set(database, + "se_34000000-0000-4000-8000-000000000002", + "2026-09-10T08:00:01Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); + CHECK(dashboard_load(database, dashboard.period, &dashboard)); + CHECK(!dashboard.error && dashboard.partial); + CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_FACT_CAPACITY); + trainlog_database_close(database); + return true; +} + +int main(void) +{ + if (!test_dashboard_characterization_empty_and_window_boundaries() || + !test_dashboard_characterization_modalities_ties_and_zones() || + !test_dashboard_characterization_session_and_fact_bounds()) return 1; + return 0; +} diff --git a/tui/tests/test_dashboard_header.c b/tui/tests/test_dashboard_header.c new file mode 100644 index 0000000..a956e59 --- /dev/null +++ b/tui/tests/test_dashboard_header.c @@ -0,0 +1,9 @@ +/* Standalone public-header compilation contract. */ +#include "trainlog/dashboard.h" + +int main(void) +{ + TrainlogDashboardQuery query = {TRAINLOG_DASHBOARD_30_DAYS, 0}; + TrainlogDashboardSnapshot snapshot = {0}; + return query.reference_unix_second == 0 && !snapshot.error ? 0 : 1; +} diff --git a/tui/tests/test_presentation_catalog.py b/tui/tests/test_presentation_catalog.py index 2f73608..d50c8a6 100644 --- a/tui/tests/test_presentation_catalog.py +++ b/tui/tests/test_presentation_catalog.py @@ -48,7 +48,7 @@ FR = re.compile(r'[À-ÿ]|\b(?:aucun|aucune|abandonner|annuler|choisir|durée|en # non-copy token: punctuation/layout, a protocol token, or an internal key. EXCLUDED = { "tui/src/tui.c": { - 1083:"—",1495:"— kg",3990:"J−%zu",3993:"M−%zu",3996:"P−%zu",4779:"msg.zone.required",5224:"statistics.zone.previous",5226:"statistics.zone.next",5290:"generator.warning.zone",5632:"—",5675:"— ",5680:"— ",5698:"— ",5713:"—",5716:"—",5840:"—",5843:"—",5846:"—",5901:"—",6332:"%-22s —",6632:"▶",6635:"▶",6663:"▶",6672:"▶",6685:"▶",6688:"▶",6712:"▶",6717:"▶",6908:"%02d/%02d … %02d/%02d",6950:"— ",7060:"PC↔Android",7106:"▶",7330:"%s · %s",7338:"—",7339:"—",7415:"—",7418:"—",7587:"—",7615:"—",7617:"—",7619:"—",7737:"aucun",7739:"aucun",7904:"—",7955:"—",8085:" · ↑",8086:" · ↓"}, + 1083:"—",1495:"— kg",3990:"J−%zu",3993:"M−%zu",3996:"P−%zu",4051:"msg.zone.required",4496:"statistics.zone.previous",4498:"statistics.zone.next",4562:"generator.warning.zone",5632:"—",5675:"— ",5680:"— ",5698:"— ",5713:"—",5716:"—",5840:"—",5843:"—",5846:"—",5901:"—",6332:"%-22s —",6632:"▶",6635:"▶",6663:"▶",6672:"▶",6685:"▶",6688:"▶",6712:"▶",6717:"▶",6908:"%02d/%02d … %02d/%02d",6950:"— ",7060:"PC↔Android",7106:"▶",7330:"%s · %s",7338:"—",7339:"—",7415:"—",7418:"—",7587:"—",7615:"—",7617:"—",7619:"—",7014:"aucun",7016:"aucun",7904:"—",7955:"—",8085:" · ↑",8086:" · ↓"}, "tui/src/chart.c": {254:"◆ %.2f %s"}, "tui/src/app_shell.c": {317:"…"}, "tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"}, diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 9505fa2..be03a8b 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -5,11 +5,13 @@ #include #include +#include + +#include "../src/database_internal.h" /* The workflows remain file-private in production. Including their translation * unit lets this test drive the exact code paths with the terminal port mocked, * without adding a public test API to Trainlog. */ -#define TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST 1 #include "../src/tui.c" struct TrainlogTerminal { @@ -1437,499 +1439,21 @@ static bool dashboard_utc_timestamp_at(time_t value, char output[TRAINLOG_TIMEST TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U; } -static bool dashboard_characterization_fix_clock(const char *timestamp) +/* Presentation-only fixture. Bucket construction itself is covered through + * trainlog_dashboard_load() in test_dashboard.c. */ +static void dashboard_prepare_render_bucket_fixture( + TrainlogDashboardSnapshot *dashboard) { - TrainlogTimestampKey key; - int64_t unix_second; - time_t converted; - if (!trainlog_timestamp_parse(timestamp, strlen(timestamp), &key)) return false; - unix_second = key.utc_second - INT64_C(719162) * INT64_C(86400); - converted = (time_t)unix_second; - if ((int64_t)converted != unix_second) return false; - dashboard_characterization_clock_value = converted; - dashboard_characterization_clock_enabled = true; - return true; -} - -static void dashboard_characterization_restore_clock(void) -{ - dashboard_characterization_clock_enabled = false; -} - -static bool dashboard_insert_max_for_exercise(TrainlogDatabase *database, - const char *session_id, const char *entry_id, const char *exercise_id, - const char *started_at, double weight) -{ - TrainlogSessionExerciseInput occurrence; - TrainlogSessionInput session; - (void)memset(&occurrence, 0, sizeof(occurrence)); - (void)memset(&session, 0, sizeof(session)); - (void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id); - (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", - exercise_id); - (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", - "leg_press"); - occurrence.recording_mode = TRAINLOG_RECORDING_SETS; - occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; - occurrence.load_mode = TRAINLOG_LOAD_NONE; - occurrence.has_max_weight = true; - occurrence.max_weight_kg = weight; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); - session.session_type = TRAINLOG_SESSION_MAX_TEST; - session.exercises = &occurrence; - session.exercise_count = 1U; - return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; -} - -static bool dashboard_insert_plan_for_exercise(TrainlogDatabase *database, - const char *session_id, const char *exercise_id, const char *started_at) -{ - TrainlogSessionExerciseInput occurrence; - TrainlogSessionInput session; - (void)memset(&occurrence, 0, sizeof(occurrence)); - (void)memset(&session, 0, sizeof(session)); - (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", - exercise_id); - (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", - "leg_press"); - occurrence.recording_mode = TRAINLOG_RECORDING_SETS; - occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; - occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL; - occurrence.target_sets = 3; - occurrence.target_reps = 10; - occurrence.target_has_weight = true; - occurrence.target_weight_kg = 50.0; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); - session.session_type = TRAINLOG_SESSION_TRAINING; - session.exercises = &occurrence; - session.exercise_count = 1U; - return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; -} - -static bool dashboard_insert_optional_weight_set(TrainlogDatabase *database, - const char *session_id, const char *exercise_id, const char *started_at, - bool has_weight, double weight) -{ - TrainlogSetInput set = {10, 0, has_weight, weight}; - TrainlogSessionExerciseInput occurrence; - TrainlogSessionInput session; - (void)memset(&occurrence, 0, sizeof(occurrence)); - (void)memset(&session, 0, sizeof(session)); - (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", - exercise_id); - (void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s", - "leg_press"); - occurrence.recording_mode = TRAINLOG_RECORDING_SETS; - occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; - occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL; - occurrence.target_sets = 1; - occurrence.target_reps = 10; - occurrence.target_has_weight = true; - occurrence.target_weight_kg = 25.0; - occurrence.sets = &set; - occurrence.set_count = 1U; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); - session.session_type = TRAINLOG_SESSION_TRAINING; - session.exercises = &occurrence; - session.exercise_count = 1U; - return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; -} - -static bool test_dashboard_characterization_empty_and_window_boundaries(void) -{ - TrainlogDatabase *database = NULL; - TrainlogAppContext app; size_t index; - CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z")); - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - (void)memset(&app, 0, sizeof(app)); - app.database = database; - app.dashboard.period = TRAINLOG_STATS_30_DAYS; - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && !app.dashboard.partial && - !app.dashboard.invalid_data); - CHECK(!app.dashboard.has_performance && !app.dashboard.has_explicit_max && - !app.dashboard.has_body); - CHECK(app.dashboard.performance_count == 0U && app.dashboard.body_count == 0U); - CHECK(app.dashboard.completed_session_count == 0U && - app.dashboard.performed_set_count == 0U && - app.dashboard.distinct_exercise_count == 0U && - app.dashboard.explicit_max_count == 0U); - /* The database is empty of training history, not of the seeded canonical - * catalogue: zone distribution is therefore deliberately non-empty. */ - CHECK(app.dashboard.zone_count == 4U); - CHECK(strcmp(app.dashboard.zones[0].label, "Non classés") == 0 && - app.dashboard.zones[0].count == 2U); - CHECK(app.dashboard.week_count == 6U); - for (index = 0U; index < app.dashboard.week_count; ++index) - CHECK(app.dashboard.weeks[index].sessions == 0U && - app.dashboard.weeks[index].maxima == 0U && - app.dashboard.weeks[index].working_improvements == 0U && - app.dashboard.weeks[index].max_improvements == 0U); - - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_33333333-3333-4333-8333-333333333333", "Frontière", - "frontiere", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(dashboard_insert_completed_set(database, - "se_31000000-0000-4000-8000-000000000001", - "2026-08-17T11:59:59Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0)); - CHECK(dashboard_insert_completed_set(database, - "se_31000000-0000-4000-8000-000000000002", - "2026-08-17T12:00:00Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); - CHECK(dashboard_insert_completed_set(database, - "se_31000000-0000-4000-8000-000000000003", - "2026-08-17T12:00:01Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 30.0)); - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && !app.dashboard.invalid_data); - CHECK(app.dashboard.completed_session_count == 2U); - CHECK(app.dashboard.performed_set_count == 2U); - CHECK(app.dashboard.distinct_exercise_count == 1U); - CHECK(app.dashboard.has_performance && app.dashboard.performance_count == 2U); - CHECK(strcmp(app.dashboard.performance[0].started_at, - "2026-08-17T12:00:00Z") == 0); - CHECK(strcmp(app.dashboard.performance[1].started_at, - "2026-08-17T12:00:01Z") == 0); - trainlog_database_close(database); - dashboard_characterization_restore_clock(); - return true; + (void)memset(dashboard->weeks, 0, sizeof(dashboard->weeks)); + dashboard->period = TRAINLOG_DASHBOARD_30_DAYS; + dashboard->week_count = 6U; + for (index = 0U; index < dashboard->week_count; ++index) { + dashboard->weeks[index].start_day = 971 + (int64_t)index * 5; + dashboard->weeks[index].span_days = 5; + } } -static bool test_dashboard_characterization_modalities_ties_and_zones(void) -{ - static const char *const primary_secondary[] = {"back"}; - TrainlogDatabase *database = NULL; - TrainlogAppContext app; - TrainlogSetInput rep_sets[] = { - {10, 0, true, 40.0}, {8, 0, true, 1000.0} - }; - TrainlogSetInput repeated_set = {12, 0, true, 50.0}; - TrainlogSetInput duration_set = {0, 60, true, 30.0}; - TrainlogSetInput assistance_set = {10, 0, true, 5.0}; - TrainlogSetInput unweighted_set = {10, 0, false, 0.0}; - TrainlogSessionExerciseInput occurrences[4]; - TrainlogSessionInput session; - bool chest = false; - bool back = false; - bool unclassified = false; - size_t working = 0U; - size_t index; - CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z")); - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_10000000-0000-4000-8000-000000000000", "MAX égalité", "max egalite", - TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_33333333-3333-4333-8333-333333333333", "Répétitions", - "repetitions", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_10000000-0000-4000-8000-000000000001", "MAX secondaire", - "max secondaire", - TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_10000000-0000-4000-8000-000000000002", "Durée séries", "duree series", - TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_10000000-0000-4000-8000-000000000003", "Continu", "continu", - TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_10000000-0000-4000-8000-000000000004", "Assistance", "assistance", - TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_10000000-0000-4000-8000-000000000005", "Poids absent antérieur", - "poids absent anterieur", TRAINLOG_TRACKING_REPS, - TRAINLOG_RECORDING_SETS, (TrainlogExerciseDataFields)0) == - TRAINLOG_STATUS_OK); - CHECK(trainlog_database_replace_exercise_body_zones(database, - "ex_33333333-3333-4333-8333-333333333333", "chest", - primary_secondary, 1U) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_replace_exercise_body_zones(database, - "ex_10000000-0000-4000-8000-000000000002", "back", NULL, 0U) == - TRAINLOG_STATUS_OK); - CHECK(trainlog_database_replace_exercise_body_zones(database, - "ex_10000000-0000-4000-8000-000000000004", "chest", NULL, 0U) == - TRAINLOG_STATUS_OK); - - (void)memset(occurrences, 0, sizeof(occurrences)); - (void)memset(&session, 0, sizeof(session)); - for (index = 0U; index < 2U; ++index) { - (void)snprintf(occurrences[index].exercise_id, - sizeof(occurrences[index].exercise_id), "%s", - "ex_33333333-3333-4333-8333-333333333333"); - (void)snprintf(occurrences[index].equipment_id, - sizeof(occurrences[index].equipment_id), "%s", "leg_press"); - occurrences[index].recording_mode = TRAINLOG_RECORDING_SETS; - occurrences[index].tracking_mode = TRAINLOG_TRACKING_REPS; - occurrences[index].load_mode = TRAINLOG_LOAD_EXTERNAL; - occurrences[index].target_sets = 1; - occurrences[index].target_reps = 10; - occurrences[index].target_has_weight = true; - occurrences[index].target_weight_kg = 40.0; - } - (void)snprintf(occurrences[0].entry_id, sizeof(occurrences[0].entry_id), "%s", - "sxe_10000000-0000-4000-8000-000000000001"); - occurrences[0].sets = rep_sets; - occurrences[0].set_count = 2U; - (void)snprintf(occurrences[1].entry_id, sizeof(occurrences[1].entry_id), "%s", - "sxe_10000000-0000-4000-8000-000000000002"); - occurrences[1].sets = &repeated_set; - occurrences[1].set_count = 1U; - (void)snprintf(occurrences[2].exercise_id, sizeof(occurrences[2].exercise_id), - "%s", "ex_10000000-0000-4000-8000-000000000002"); - (void)snprintf(occurrences[2].equipment_id, sizeof(occurrences[2].equipment_id), - "%s", "leg_press"); - occurrences[2].recording_mode = TRAINLOG_RECORDING_SETS; - occurrences[2].tracking_mode = TRAINLOG_TRACKING_DURATION; - occurrences[2].load_mode = TRAINLOG_LOAD_EXTERNAL; - occurrences[2].target_sets = 1; - occurrences[2].target_duration_seconds = 60; - occurrences[2].target_has_weight = true; - occurrences[2].target_weight_kg = 30.0; - occurrences[2].sets = &duration_set; - occurrences[2].set_count = 1U; - (void)snprintf(occurrences[3].exercise_id, sizeof(occurrences[3].exercise_id), - "%s", "ex_10000000-0000-4000-8000-000000000003"); - occurrences[3].recording_mode = TRAINLOG_RECORDING_CONTINUOUS; - occurrences[3].tracking_mode = TRAINLOG_TRACKING_DURATION; - occurrences[3].load_mode = TRAINLOG_LOAD_NONE; - occurrences[3].continuous_duration_seconds = 600; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_32000000-0000-4000-8000-000000000001"); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", - "2026-09-10T08:00:00Z"); - session.session_type = TRAINLOG_SESSION_TRAINING; - session.exercises = occurrences; - session.exercise_count = 4U; - CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); - CHECK(dashboard_insert_optional_weight_set(database, - "se_32000000-0000-4000-8000-000000000009", - "ex_10000000-0000-4000-8000-000000000005", "2026-09-08T08:00:00Z", - false, 0.0)); - CHECK(dashboard_insert_optional_weight_set(database, - "se_32000000-0000-4000-8000-000000000010", - "ex_10000000-0000-4000-8000-000000000005", "2026-09-09T08:00:00Z", - true, 25.0)); - - (void)memset(&occurrences[0], 0, sizeof(occurrences[0])); - (void)memset(&session, 0, sizeof(session)); - (void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id), - "%s", "ex_10000000-0000-4000-8000-000000000004"); - (void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id), - "%s", "assisted_dip_chin_machine"); - occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS; - occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS; - occurrences[0].load_mode = TRAINLOG_LOAD_ASSISTANCE; - occurrences[0].target_sets = 1; - occurrences[0].target_reps = 10; - occurrences[0].target_has_weight = true; - occurrences[0].target_weight_kg = 5.0; - occurrences[0].sets = &assistance_set; - occurrences[0].set_count = 1U; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_32000000-0000-4000-8000-000000000002"); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", - "2026-09-11T08:00:00Z"); - (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", - "2026-09-11T09:00:00Z"); - session.session_type = TRAINLOG_SESSION_TRAINING; - session.exercises = occurrences; - session.exercise_count = 1U; - CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); - CHECK(dashboard_insert_plan_for_exercise(database, - "se_32000000-0000-4000-8000-000000000003", - "ex_10000000-0000-4000-8000-000000000001", "2026-09-12T08:00:00Z")); - (void)memset(&occurrences[0], 0, sizeof(occurrences[0])); - (void)memset(&session, 0, sizeof(session)); - (void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id), - "%s", "ex_33333333-3333-4333-8333-333333333333"); - (void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id), - "%s", "leg_press"); - occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS; - occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS; - occurrences[0].load_mode = TRAINLOG_LOAD_EXTERNAL; - occurrences[0].target_sets = 1; - occurrences[0].target_reps = 10; - occurrences[0].target_has_weight = true; - occurrences[0].target_weight_kg = 40.0; - occurrences[0].sets = &unweighted_set; - occurrences[0].set_count = 1U; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_32000000-0000-4000-8000-000000000008"); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", - "2026-09-12T12:00:00Z"); - session.session_type = TRAINLOG_SESSION_TRAINING; - session.exercises = occurrences; - session.exercise_count = 1U; - CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); - CHECK(dashboard_insert_completed_set_dose(database, - "se_32000000-0000-4000-8000-000000000004", "2026-09-13T08:00:00Z", - "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 45.0, 10)); - CHECK(dashboard_insert_completed_set_dose(database, - "se_32000000-0000-4000-8000-000000000005", "2026-09-13T08:00:00Z", - "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 50.0, 10)); - CHECK(dashboard_insert_max_for_exercise(database, - "se_32000000-0000-4000-8000-000000000006", - "sxe_32000000-0000-4000-8000-000000000006", - "ex_10000000-0000-4000-8000-000000000001", "2026-09-14T08:00:00Z", 100.0)); - CHECK(dashboard_insert_max_for_exercise(database, - "se_32000000-0000-4000-8000-000000000007", - "sxe_32000000-0000-4000-8000-000000000007", - "ex_10000000-0000-4000-8000-000000000000", "2026-09-14T08:00:00Z", 200.0)); - - (void)memset(&app, 0, sizeof(app)); - app.database = database; - app.dashboard.period = TRAINLOG_STATS_30_DAYS; - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && !app.dashboard.partial && - !app.dashboard.invalid_data); - CHECK(app.dashboard.completed_session_count == 9U); - CHECK(app.dashboard.performed_set_count == 10U); - CHECK(app.dashboard.explicit_max_count == 2U); - CHECK(app.dashboard.distinct_exercise_count == 7U); - CHECK(app.dashboard.has_performance); - CHECK(strcmp(app.dashboard.exercise.exercise_id, - "ex_33333333-3333-4333-8333-333333333333") == 0); - CHECK(app.dashboard.performance_tracking_mode == TRAINLOG_TRACKING_REPS && - app.dashboard.performance_dose == 10); - CHECK(app.dashboard.performance_count == 4U); - CHECK(app.dashboard.performance[0].weight_kg == 40.0 && - app.dashboard.performance[1].has_weight == 1 && - app.dashboard.performance[1].weight_kg == 0.0 && - app.dashboard.performance[2].weight_kg == 45.0 && - app.dashboard.performance[3].weight_kg == 50.0); - CHECK(app.dashboard.has_explicit_max && app.dashboard.max_weight_kg == 200.0); - CHECK(strcmp(app.dashboard.max_exercise.exercise_id, - "ex_10000000-0000-4000-8000-000000000000") == 0); - for (index = 0U; index < app.dashboard.week_count; ++index) - working += app.dashboard.weeks[index].working_improvements; - /* Existing behavior: a matching earlier unweighted set supplies a 0.0 - * baseline and therefore makes the later weighted set an improvement. */ - CHECK(working == 3U); - CHECK(app.dashboard.zone_count == 4U); - for (index = 0U; index < app.dashboard.zone_count; ++index) { - chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 && - app.dashboard.zones[index].count == 3U); - back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 && - app.dashboard.zones[index].count == 2U); - unclassified = unclassified || - (strcmp(app.dashboard.zones[index].label, "Non classés") == 0 && - app.dashboard.zones[index].count == 6U); - } - CHECK(chest && back && unclassified); - { - TrainlogExercisePerformancePoint left; - TrainlogExercisePerformancePoint right; - (void)memset(&left, 0, sizeof(left)); - (void)memset(&right, 0, sizeof(right)); - (void)snprintf(left.started_at, sizeof(left.started_at), "%s", - "2026-09-10T08:00:00Z"); - (void)snprintf(right.started_at, sizeof(right.started_at), "%s", - left.started_at); - (void)snprintf(left.session_id, sizeof(left.session_id), "%s", - "se_32000000-0000-4000-8000-000000000011"); - (void)snprintf(right.session_id, sizeof(right.session_id), "%s", - left.session_id); - left.weight_kg = 10.0; - right.weight_kg = 20.0; - CHECK(dashboard_performance_time_compare(&left, &right) == 0); - } - trainlog_database_close(database); - dashboard_characterization_restore_clock(); - return true; -} - -static bool test_dashboard_characterization_session_and_fact_bounds(void) -{ - TrainlogDatabase *database = NULL; - TrainlogAppContext app; - TrainlogSetInput *sets = NULL; - TrainlogSessionExerciseInput occurrence; - TrainlogSessionInput session; - size_t index; - CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z")); - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_33333333-3333-4333-8333-333333333333", "Bornes", "bornes", - TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - for (index = 0U; index < MAX_SESSIONS; ++index) { - char session_id[TRAINLOG_ID_MAX + 1U]; - (void)snprintf(session_id, sizeof(session_id), - "se_33000000-0000-4000-8000-%012zu", index); - CHECK(dashboard_insert_completed_set(database, session_id, - "2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); - } - (void)memset(&app, 0, sizeof(app)); - app.database = database; - app.dashboard.period = TRAINLOG_STATS_ALL; - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && app.dashboard.partial); - CHECK(app.dashboard.completed_session_count == MAX_SESSIONS); - CHECK(app.dashboard.performed_set_count == MAX_SESSIONS); - CHECK(dashboard_insert_completed_set(database, - "se_33000000-0000-4000-8000-000000000128", - "2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0)); - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && app.dashboard.partial); - CHECK(app.dashboard.completed_session_count == MAX_SESSIONS); - CHECK(app.dashboard.performed_set_count == MAX_SESSIONS + 1U); - trainlog_database_close(database); - - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_insert_exercise_profiled(database, - "ex_33333333-3333-4333-8333-333333333333", "Faits bornés", - "faits bornes", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, - (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); - sets = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*sets)); - CHECK(sets != NULL); - (void)memset(&occurrence, 0, sizeof(occurrence)); - (void)memset(&session, 0, sizeof(session)); - (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", - "ex_33333333-3333-4333-8333-333333333333"); - occurrence.recording_mode = TRAINLOG_RECORDING_SETS; - occurrence.tracking_mode = TRAINLOG_TRACKING_REPS; - occurrence.load_mode = TRAINLOG_LOAD_NONE; - occurrence.target_sets = 1; - occurrence.target_reps = 1; - occurrence.sets = sets; - occurrence.set_count = DASHBOARD_FACT_CAPACITY; - (void)snprintf(session.session_id, sizeof(session.session_id), "%s", - "se_34000000-0000-4000-8000-000000000001"); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", - "2026-09-10T08:00:00Z"); - session.session_type = TRAINLOG_SESSION_TRAINING; - session.exercises = &occurrence; - session.exercise_count = 1U; - CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); - free(sets); - sets = NULL; - (void)memset(&app, 0, sizeof(app)); - app.database = database; - app.dashboard.period = TRAINLOG_STATS_ALL; - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && !app.dashboard.partial); - CHECK(app.dashboard.performed_set_count == DASHBOARD_FACT_CAPACITY); - CHECK(sqlite3_exec(database->connection, - "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) " - "SELECT id,4096,0,NULL FROM session_exercises WHERE " - "entry_id=(SELECT entry_id FROM session_exercises LIMIT 1);", - NULL, NULL, NULL) == SQLITE_OK); - app_shell_load_dashboard(&app); - CHECK(!app.dashboard.error && app.dashboard.partial); - CHECK(app.dashboard.performed_set_count == DASHBOARD_FACT_CAPACITY); - trainlog_database_close(database); - dashboard_characterization_restore_clock(); - return true; -} static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) { @@ -1946,38 +1470,12 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) app.dashboard.performed_set_count = 9U; app.dashboard.distinct_exercise_count = 3U; app.dashboard.explicit_max_count = 1U; - dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + dashboard_prepare_render_bucket_fixture(&app.dashboard); app.dashboard.weeks[5].sessions = 4U; app.dashboard.weeks[5].maxima = 1U; app.dashboard.weeks[5].working_improvements = 2U; app.dashboard.weeks[5].max_improvements = 1U; - /* CONTRACT: the selector changes the shared projection used by progress - * and frequency; it is not a label over an invariant six-week chart. */ - { - static const TrainlogStatisticsPeriod periods[] = { - TRAINLOG_STATS_7_DAYS, TRAINLOG_STATS_30_DAYS, - TRAINLOG_STATS_90_DAYS, TRAINLOG_STATS_YEAR, TRAINLOG_STATS_ALL - }; - static const size_t counts[] = {7U, 6U, 6U, 12U, 12U}; - static const int64_t spans[] = {1, 5, 15, 31, 84}; - size_t period_index; - for (period_index = 0U; period_index < 5U; ++period_index) { - app.dashboard.period = periods[period_index]; - dashboard_prepare_period_buckets(&app.dashboard, 1000, 0); - CHECK(app.dashboard.week_count == counts[period_index]); - CHECK(app.dashboard.weeks[0].span_days == spans[period_index]); - CHECK(strcmp(app.dashboard.weeks[ - app.dashboard.week_count - 1U].label, "actuel") == 0); - } - app.dashboard.period = TRAINLOG_STATS_30_DAYS; - dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); - app.dashboard.weeks[5].sessions = 4U; - app.dashboard.weeks[5].maxima = 1U; - app.dashboard.weeks[5].working_improvements = 2U; - app.dashboard.weeks[5].max_improvements = 1U; - } - /* A lone measurement is text-only; event blocks are factual period-bucket * counts and the accessible totals expose their meaning. */ dashboard_prepare_render(&app, &terminal, &surface, 120, 35, 96, 29); @@ -1994,7 +1492,7 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) /* Frequency geometry has one shared x coordinate per period bucket. * The end buckets also prove a wide chart uses its useful span. */ (void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks)); - dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + dashboard_prepare_render_bucket_fixture(&app.dashboard); for (index = 0U; index < 6U; ++index) { app.dashboard.weeks[index].sessions = 11U + index; app.dashboard.weeks[index].maxima = 1U; @@ -2003,7 +1501,9 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) app_shell_dashboard_frequency_chart(&app, &app.dashboard, 11, 4, 88, 7); for (index = 0U; index < 6U; ++index) { char count[8]; - const char *label = app.dashboard.weeks[index].label; + char label[12]; + dashboard_period_bucket_label(app.dashboard.period, index, + app.dashboard.week_count, label); int label_column; int x; (void)snprintf(count, sizeof(count), "%zu", 11U + index); @@ -2066,7 +1566,7 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void) /* No classified performance events still occupies the selected-period * timeline, with dots and an explicit explanation. */ (void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks)); - dashboard_prepare_period_buckets(&app.dashboard, 1000, 971); + dashboard_prepare_render_bucket_fixture(&app.dashboard); app.dashboard.body_count = 1U; dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); app_shell_render_dashboard(&app); @@ -2313,11 +1813,11 @@ static bool test_stats_dashboard_uses_represented_local_weeks(void) (void)memset(&app, 0, sizeof(app)); app.database = database; - app.dashboard.period = TRAINLOG_STATS_ALL; + app.dashboard.period = TRAINLOG_DASHBOARD_ALL; app_shell_load_dashboard(&app); CHECK(!app.dashboard.error); CHECK(app.dashboard.week_count >= 1U && - app.dashboard.week_count <= DASHBOARD_BUCKET_COUNT); + app.dashboard.week_count <= TRAINLOG_DASHBOARD_BUCKET_CAPACITY); /* Tout projects the complete represented history instead of silently * retaining the former six-week viewport. */ { @@ -2354,11 +1854,11 @@ static bool test_stats_dashboard_current_week_remains_zero_after_past_activity(v TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0)); (void)memset(&app, 0, sizeof(app)); app.database = database; - app.dashboard.period = TRAINLOG_STATS_ALL; + app.dashboard.period = TRAINLOG_DASHBOARD_ALL; app_shell_load_dashboard(&app); CHECK(!app.dashboard.error); CHECK(app.dashboard.week_count >= 1U && - app.dashboard.week_count <= DASHBOARD_BUCKET_COUNT); + app.dashboard.week_count <= TRAINLOG_DASHBOARD_BUCKET_CAPACITY); CHECK(app.dashboard.weeks[app.dashboard.week_count - 1U].sessions == 0U); dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); app_shell_dashboard_frequency_chart(&app, &app.dashboard, 3, 4, 64, 3); @@ -2435,7 +1935,7 @@ static bool test_stats_dashboard_exact_dose_strict_later_and_invalid_rows(void) (void)memset(&app, 0, sizeof(app)); app.database = database; - app.dashboard.period = TRAINLOG_STATS_ALL; + app.dashboard.period = TRAINLOG_DASHBOARD_ALL; app_shell_load_dashboard(&app); CHECK(!app.dashboard.error && app.dashboard.invalid_data); CHECK(app.dashboard.completed_session_count == 7U); @@ -2611,16 +2111,16 @@ static bool test_stats_detail_charts_and_global_zone_projection(void) "ex_10000000-0000-4000-8000-000000000001", "ex_10000000-0000-4000-8000-000000000002") == TRAINLOG_STATUS_OK); (void)memset(&app, 0, sizeof(app)); app.database = database; - app.dashboard.period = TRAINLOG_STATS_ALL; + app.dashboard.period = TRAINLOG_DASHBOARD_ALL; app_shell_load_dashboard(&app); CHECK(!app.dashboard.error && app.dashboard.zone_count == 4U); for (index = 0U; index < app.dashboard.zone_count; ++index) { - chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 && + chest = chest || (strcmp(app.dashboard.zones[index].zone_id, "chest") == 0 && app.dashboard.zones[index].count == 2U); - back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 && + back = back || (strcmp(app.dashboard.zones[index].zone_id, "back") == 0 && app.dashboard.zones[index].count == 2U); unclassified = unclassified || - (strcmp(app.dashboard.zones[index].label, "Non classés") == 0 && + (app.dashboard.zones[index].zone_id[0] == '\0' && app.dashboard.zones[index].count == 3U); } CHECK(chest && back && unclassified); @@ -2640,8 +2140,8 @@ static bool test_stats_detail_charts_and_global_zone_projection(void) size_t geometry; app.dashboard.zone_count = sizeof(counts) / sizeof(counts[0]); for (index = 0U; index < app.dashboard.zone_count; ++index) { - (void)snprintf(app.dashboard.zones[index].label, - sizeof(app.dashboard.zones[index].label), "Zone %zu", index + 1U); + (void)snprintf(app.dashboard.zones[index].zone_id, + sizeof(app.dashboard.zones[index].zone_id), "Zone %zu", index + 1U); app.dashboard.zones[index].count = counts[index]; } for (geometry = 0U; geometry < 4U; ++geometry) { @@ -2986,9 +2486,6 @@ int main(void) !test_shell_sync_history_neutralizes_legacy_directions() || !test_equipment_collector_finds_match_after_first_pages() || !test_exercise_merge_action_search_confirm_selects_target() || - !test_dashboard_characterization_empty_and_window_boundaries() || - !test_dashboard_characterization_modalities_ties_and_zones() || - !test_dashboard_characterization_session_and_fact_bounds() || !test_stats_dashboard_sparse_series_names_frequency_and_footer() || !test_stats_dashboard_uses_real_exact_time_snapshots_responsively() || !test_stats_dashboard_uses_represented_local_weeks() ||