From 89abf531a3c69fd4bbcb6a0391abd5dd45fecf2d Mon Sep 17 00:00:00 2001 From: fy59 Date: Tue, 15 Sep 2026 07:42:48 +0200 Subject: [PATCH] feat: add calendar-based TUI training statistics --- CHANGELOG.md | 44 ++ README.md | 2 +- docs/current_state.md | 29 +- docs/roadmap.md | 8 +- docs/tests.md | 38 +- docs/tui.md | 94 +++- tui/include/trainlog/app_shell.h | 3 + tui/include/trainlog/chart.h | 90 ++++ tui/include/trainlog/statistics.h | 186 +++++++ tui/meson.build | 23 + tui/src/app_shell.c | 8 +- tui/src/chart.c | 425 +++++++++++++++ tui/src/statistics.c | 859 ++++++++++++++++++++++++++++++ tui/src/tui.c | 818 +++++++++++++++++++++++----- tui/tests/test_chart.c | 181 +++++++ tui/tests/test_statistics.c | 359 +++++++++++++ tui/tests/test_tui_workflows.c | 155 +++++- 17 files changed, 3147 insertions(+), 175 deletions(-) create mode 100644 tui/include/trainlog/chart.h create mode 100644 tui/include/trainlog/statistics.h create mode 100644 tui/src/chart.c create mode 100644 tui/src/statistics.c create mode 100644 tui/tests/test_chart.c create mode 100644 tui/tests/test_statistics.c diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd94a2..5d178f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,50 @@ Detailed implementation chronology remains available in Git history and ### Added +- `TRAINLOG_TUI_STATISTICS_CALENDAR_BUCKETS_V1=PASS`: Training `7j` now means + the current Monday–Sunday calendar week and compares calendar weeks; `30j` + means the current Gregorian month and compares calendar months. Membership + follows the civil date persisted in each timestamp, empty intermediate + periods remain zero bars, localized labels expose the calendar boundaries, + and exact month endpoints cover ordinary and leap-year February. + +- `TRAINLOG_TUI_STATISTICS_PERIOD_BUCKET_SIZE_FIX=PASS`: the selected Training + period now controls both summary depth and comparison-bucket size. `7j` uses + exact consecutive 7-day buckets, `30j` uses exact consecutive 30-day buckets + anchored on the current local date, and `Tout` selects one stable weekly or + calendar-month scale from total historical duration. Empty intervals remain + explicit zero bars and boundary observations are counted once. + +- `TRAINLOG_TUI_STATISTICS_PERIOD_BUCKETING_V1=PASS`: Training summaries keep + their rolling 7-day/30-day facts while charts compare consecutive exact + Monday–Sunday buckets. The 7-day view shows up to four available weeks, the + 30-day view shows all intersecting weeks, empty periods remain zero bars, and + complete history adapts from weekly to monthly totals beyond 128 active + weeks without sampling away totals. + +- `TRAINLOG_TUI_STATISTICS_REFINEMENT_V1=PASS`: finite statistics windows now + retain their complete time domain; weekly totals use local Monday buckets, + explicit empty weeks, zero baselines and discrete Notcurses bars. Wide + training summaries are grouped into semantic panels, zone bars expose raw + counts on one shared scale, exercise lists show factual summaries, and MAX + defaults to exercises with an explicit recorded result while preserving an + opt-in all-compatible view and prefix search. + +- `TRAINLOG_TUI_STATISTICS_V1=PASS`: Statistiques is now the desktop analysis + center for body history, factual global training activity, canonical + exercise histories, persisted primary/secondary body-zone activity and + explicit MAX history. Read-only C17 aggregations feed responsive Notcurses + views and the shared Braille chart; valid loaded volume, planned/actual and + feedback ownership rules deliberately exclude unsupported inference. No + Android, database-schema, synchronization or exchange-format behavior changed. + +- `TRAINLOG_TUI_MEASUREMENTS_GRAPH_V1=PASS`: Statistiques / Mensurations now + uses a reusable Notcurses chart with true elapsed-time spacing, labelled + padded value scales, a 2x4 Unicode Braille line raster, distinct accent + markers for stored observations, responsive sparse-profile layout, and + terminal-free geometry/raster regressions. No database, measurement, Android, + or synchronization semantics changed. + - `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`: a shared seven-root application shell—Accueil, Séances, Exercices, Équipements, Statistiques, Synchronisation and Paramètres—on the Notcurses TUI and Android Material 3 diff --git a/README.md b/README.md index 446f5ec..c9dcef1 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ BODY_ZONE_SYNC_V1=PASS BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=47/47 PASS (latest validated checkpoint) +DESKTOP_TESTS=58/58 PASS (latest validated checkpoint) ANDROID_BUILD=PASS TRAINING_KNOWLEDGE_V1=PASS diff --git a/docs/current_state.md b/docs/current_state.md index c6ea813..eb31763 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -109,7 +109,30 @@ Implemented: - multiple occurrences of one catalogue exercise in a session; - per-set actual loads with distinct external/assistance semantics; - body-observation creation/history/editing; -- body graphs and normalized overlays; +- `TRAINLOG_TUI_MEASUREMENTS_GRAPH_V1=PASS`: a reusable Notcurses body-history + chart with elapsed-time X spacing, labelled and margined Y scale, Braille 2x4 + line rasterization, distinct real-measurement markers, responsive geometry, + and terminal-free calculation/raster regressions; +- `TRAINLOG_TUI_STATISTICS_V1=PASS`: the TUI statistics hub covers recorded + body history, global 7-day/30-day/all-time activity, canonical-exercise + facts and graphs, persisted primary/secondary body-zone activity, and + explicit MAX history. Aggregations are read-only and terminal-independent; + volume, planning and feedback scopes remain conservative and auditable; +- `TRAINLOG_TUI_STATISTICS_REFINEMENT_V1=PASS`: finite graph windows preserve + their selected domain, weekly totals use zero-based discrete bars and local + Monday buckets, wide summaries use semantic panels, zone bars share one + numeric scale, and MAX defaults to explicitly measured exercises; +- `TRAINLOG_TUI_STATISTICS_PERIOD_BUCKETING_V1=PASS`: the 7-day summary compares + up to four consecutive calendar-week buckets and preserves empty periods; +- `TRAINLOG_TUI_STATISTICS_PERIOD_BUCKET_SIZE_FIX=PASS`: historical checkpoint + that separated bucket sizes before the subsequent calendar alignment. Its + duration-based full-history adaptation remains active; +- `TRAINLOG_TUI_STATISTICS_CALENDAR_BUCKETS_V1=PASS`: the current `7j` summary + and bars are Monday–Sunday calendar weeks, while `30j` uses the current and + preceding Gregorian calendar months. Persisted civil dates determine bucket + membership without reinterpretation through the machine timezone; empty + weeks/months and partial current periods remain explicit; +- normalized body overlays; - exercise performance history; - direct USB/MTP device access; - manual Android -> PC, PC -> Android, and bidirectional synchronization; @@ -517,7 +540,7 @@ ASSISTANCE_DIRECTION_AWARE=PASS ANDROID_MAX_TEST_SESSION=PASS EXPLICIT_MAX_RESULTS_V1=PASS MAX_TEST_RESUME_STABLE_ID=PASS -DESKTOP_TESTS=47/47 PASS +DESKTOP_TESTS=58/58 PASS ``` A measured maximum belongs to an exercise occurrence in an explicit `max_test` @@ -556,7 +579,7 @@ BODY_COMPOSITION_ESTIMATE=PASS BODY_PROPORTION_RATIOS=PASS BODY_SYMMETRY_ANALYTICS=PASS NO_ESTIMATE_PERSISTENCE=PASS -DESKTOP_TESTS=47/47 PASS +DESKTOP_TESTS=58/58 PASS ``` Android remains capture-only for this feature. diff --git a/docs/roadmap.md b/docs/roadmap.md index bbe49f7..9a3d76d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -59,8 +59,14 @@ BODY_ZONES_V1=PASS BODY_ZONE_SYNC_V1=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=47/47 PASS (latest validated checkpoint) +DESKTOP_TESTS=58/58 PASS (latest validated checkpoint) TUI_NOTCURSES_V1=PASS +TRAINLOG_TUI_MEASUREMENTS_GRAPH_V1=PASS +TRAINLOG_TUI_STATISTICS_V1=PASS +TRAINLOG_TUI_STATISTICS_REFINEMENT_V1=PASS +TRAINLOG_TUI_STATISTICS_PERIOD_BUCKETING_V1=PASS +TRAINLOG_TUI_STATISTICS_PERIOD_BUCKET_SIZE_FIX=PASS +TRAINLOG_TUI_STATISTICS_CALENDAR_BUCKETS_V1=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NOTCURSES_TRUECOLOR_THEME=PASS ``` diff --git a/docs/tests.md b/docs/tests.md index 4cd817d..2628757 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -244,7 +244,7 @@ Strict warning flags remain active. Do not weaken warnings to make a change pass ## APP_SHELL_V1 production-transition coverage -The active desktop suite has 46 tests. `app_shell` verifies layout thresholds, +The active desktop suite has 58 tests. `app_shell` verifies layout thresholds, route/history bounds, overlays and focus restoration, bounded UTF-8 search and form input, stable-ID list selection, shared action ordering, and leave guards. `tui_workflows` covers the production single event-loop routes and controller @@ -255,10 +255,38 @@ invalid arguments, offsets and corrupt values. This coverage replaces former nested-screen-loop workflow claims. The Mensurations workflow regression separates the selected observation's -profile from its metric history: singleton circumference bars without a trend, -independent kg/cm rendering, missing-field omission, a real two-date Unicode -series, historical selection updates, availability-filtered metric navigation, -and bounded 120x35, 100x30, 80x24 and 72x20 geometry without ASCII chart glyphs. +profile from its metric history: singleton circumference bars, independent +kg/cm rendering, missing-field omission, a real two-date high-resolution +Unicode series, historical selection updates, availability-filtered metric +navigation, and bounded 120x35, 100x30, 80x24 and 72x20 geometry without ASCII +chart glyphs. The terminal-free `chart` regression exercises zero, one and two +points; constant, increasing and decreasing series; irregular elapsed-time X +mapping; Y headroom; clipping and compact rectangles; Braille 2x4 dot encoding; +interpolated Braille output; and distinct stored-measurement markers. + +The terminal-free `statistics` regression covers empty, 7-day, 30-day and +complete windows; actual-session and occurrence counts; sets, repetitions and +duration; strict external-load volume; same-occurrence planned/actual totals; +canonical alias reconciliation; primary and secondary persisted zones; +immediate and session-global feedback scopes; and zero/one/multiple explicit +MAX histories. `tui_workflows` additionally exercises the five-entry statistics +hub, period and graph cycling, compact zone bars, canonical exercise detail, +MAX facts, and bounded common-chart rendering without an interactive terminal. + +Statistics-refinement regressions distinguish identical observations plotted +inside 7-day and 30-day domains, clamp non-negative scales to zero, render two +weekly totals as independent bars, retain empty local-calendar weeks, compare +unequal zone counts through one shared scale, and verify that the default MAX +list excludes never-measured exercises until its explicit toggle is used. + +Period-bucketing regressions cover four consecutive 7-day comparison buckets, +Monday/Sunday assignment, localized consecutive Gregorian months, retained +empty middle periods, and boundary assignment without double counting. +Ordinary February, leap-year February and exact first/last dates verify month +length without fixed-day arithmetic. Offset-bearing fixtures verify persisted +civil-date membership independently of the machine timezone. Current partial +weeks/months, stable zero history, and duration-based adaptive full-history +aggregation are also covered. The APP_SHELL PTY validation exercises six TUI sizes—72x20, 80x24, 100x25, 100x30, 120x31 and 120x35—plus help, search clear/close, F6/F7, compact focus, diff --git a/docs/tui.md b/docs/tui.md index 65534d5..36963df 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -326,9 +326,14 @@ visibly distinct; `i` opens the resolved equipment detail. scaled to the largest circumference in that observation; - `←`/`→` selection among only metrics that exist in recorded observations, with weight selected by default when available; -- an evolution section that reports no data for zero points, reports - `1 relevé · tendance indisponible` without a graph for one point, and draws a - Unicode time series from real dated values for two or more points; +- a reusable Notcurses evolution chart whose X axis represents elapsed time, + whose labelled Y axis adds modest visible headroom, and whose 2x4 Braille + raster draws a fine piecewise-linear line between stored observations; +- distinct accent diamonds for actual measurements, including one centered + point with a readable scale for a singleton series; zero points show + `Aucune donnée disponible.` without unnecessary axes; +- first/last civil-date labels and a non-colliding intermediate graduation + when the available width permits it; - detail and correction; - body trend visualization; - normalized multi-metric overlay; @@ -336,8 +341,10 @@ visibly distinct; `i` opens the resolved equipment detail. The profile and evolution sections are deliberately separate: kilograms never share a circumference scale, missing measurements are omitted, and the profile -bars make no progress or trend claim. The sections stack responsively down to -the supported 72x20 terminal. +bars make no progress or trend claim. Sparse profiles donate unused rows to the +chart. The current content-plane rectangle is recalculated on every render, so +the sections resize responsively down to the supported 72x20 terminal without +retaining stale coordinates. Editing preserves observation identity, timestamp, and optional session link. @@ -638,7 +645,82 @@ Proportions can display: All estimates are explicitly labeled as estimates. No result is converted into a medical or diagnostic classification. -## 18. Training knowledge infrastructure +## 18. Statistics center + +`TRAINLOG_TUI_STATISTICS_V1=PASS` organizes the Notcurses statistics route as +five keyboard-first views: Corps, Vue globale, Exercices, Zones and MAX. The +body view retains recorded weight and circumference history. The other views +use read-only aggregates over the existing desktop schema and never persist a +derived score or cache. + +The global view switches between the current calendar week, current calendar +month and complete history. It reports actual sessions, occurrences, sets, repetitions, +available durations, active days/weeks and loaded volume. Loaded volume is +strictly `repetitions × actual external load` when both the occurrence and the +exercise declare external-load semantics; duration, cardio, assistance and + no-load work are excluded. Its common Notcurses chart cycles through bucketed + loaded volume, sessions, sets and immediate-feedback counts. + +Exercise statistics are selected by canonical `exercise_id`, include aliases +already reconciled by persistence, and omit inapplicable load facts. Their +graph cycles through recorded load, valid volume, repetitions, sets and +explicit MAX values. The MAX detail displays only persisted explicit tests, +including latest, previous, historical best and auditable deltas. + +Zone statistics count distinct actual sessions through persisted +`exercise_body_zones` primary/secondary associations. Exercise names are never +used to infer a zone. Planning comparisons are limited to targets and actuals +owned by the same persisted occurrence. Immediate feedback remains attached +to its occurrence; J+1 feedback remains session-global, and free text is not +classified or attributed automatically. + +`TRAINLOG_TUI_STATISTICS_REFINEMENT_V1=PASS` keeps these five routes while +making their visual semantics explicit. Finite 7-day and 30-day graphs retain +the complete selected time domain, including inactive space. Weekly totals use +Monday-based local calendar buckets, explicit empty weeks and discrete bars; +non-negative counts and volumes use a zero baseline. Continuous observations, +exercise progression and explicit MAX histories remain line graphs. + +On sufficiently large planes, the training summary is grouped into Activity, +Work, Feedback and Planned/Performed panels; compact planes retain the bounded +vertical form. Zone primary and secondary bars share one scale across every +visible row and show their raw counts. The MAX catalogue defaults to exercises +with an explicit result, shows latest value/date, and uses `a` to reveal all +compatible set-based exercises. Prefix search and stable-ID drill-down are +unchanged. + +`TRAINLOG_TUI_STATISTICS_PERIOD_BUCKETING_V1=PASS` separates the textual +summary window from the comparison depth of its chart. `7j` summarizes the +rolling last seven days but compares up to four available consecutive local +calendar weeks. + +`TRAINLOG_TUI_STATISTICS_PERIOD_BUCKET_SIZE_FIX=PASS` is the historical +checkpoint that first separated comparison sizes. Its duration-based `Tout` +adaptation remains active; its finite rolling boundaries are superseded by the +calendar contract below. `Tout` uses Monday-based 7-day buckets while its +elapsed span fits the bounded graph, then switches the whole graph to calendar +months. The decision uses historical duration, so sparse old activity is not +silently discarded. + +Explicit zero buckets remain visible between represented boundaries. Each +chart uses one bucket size and one zero-based scale, and its title explicitly +states 7-day periods, 30-day periods, complete-history weeks, or +complete-history months. + +`TRAINLOG_TUI_STATISTICS_CALENDAR_BUCKETS_V1=PASS` supersedes rolling bucket +boundaries with calendar alignment. `7j` summarizes the current local calendar +week and compares Monday–Sunday weeks. `30j` summarizes the current Gregorian +calendar month and compares the current month with up to three preceding +months. The current week or month remains partial until its calendar endpoint. + +Historical membership uses the `YYYY-MM-DD` civil date stored in each persisted +timestamp. It is never reassigned by converting that timestamp through the +current machine timezone. Month keys advance by Gregorian year/month, so +28-day February, leap-year February, 30-day months and 31-day months all expose +their exact exclusive chart endpoint. French abbreviated month labels and +Monday–Sunday ranges make the aggregation visible in the TUI. + +## 19. Training knowledge infrastructure The desktop core includes immutable `training_knowledge.h` catalog access and read-only `training_context.h` composition. The context uses real persisted IDs diff --git a/tui/include/trainlog/app_shell.h b/tui/include/trainlog/app_shell.h index 7ada350..215ef05 100644 --- a/tui/include/trainlog/app_shell.h +++ b/tui/include/trainlog/app_shell.h @@ -54,7 +54,10 @@ typedef enum TrainlogAppRoute { TRAINLOG_ROUTE_EQUIPMENT, TRAINLOG_ROUTE_EQUIPMENT_DETAIL, TRAINLOG_ROUTE_STATS, + TRAINLOG_ROUTE_STATS_TRAINING, TRAINLOG_ROUTE_STATS_EXERCISE, + TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL, + TRAINLOG_ROUTE_STATS_ZONES, TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_BODY_DETAIL, TRAINLOG_ROUTE_BODY_METRIC, diff --git a/tui/include/trainlog/chart.h b/tui/include/trainlog/chart.h new file mode 100644 index 0000000..ad8d2e4 --- /dev/null +++ b/tui/include/trainlog/chart.h @@ -0,0 +1,90 @@ +#ifndef TRAINLOG_CHART_H +#define TRAINLOG_CHART_H + +/** + * @file chart.h + * @brief Reusable, time-scaled Braille chart for Trainlog Notcurses surfaces. + */ + +#include +#include +#include + +#include "trainlog/terminal.h" + +typedef struct TrainlogChartRect { + int top; + int left; + int height; + int width; +} TrainlogChartRect; + +typedef struct TrainlogChartPoint { + int64_t timestamp; + double value; + /* Optional borrowed RFC3339 text used only for its YYYY-MM-DD label. */ + const char *timestamp_label; +} TrainlogChartPoint; + +typedef struct TrainlogChartScale { + int64_t minimum_timestamp; + int64_t maximum_timestamp; + double minimum_value; + double maximum_value; +} TrainlogChartScale; + +typedef struct TrainlogChartOptions { + bool has_time_domain; + int64_t minimum_timestamp; + int64_t maximum_timestamp; + bool non_negative; + bool zero_baseline; +} TrainlogChartOptions; + +/** + * Build the visible data scale, including a deterministic Y margin. + * + * CONTRACT: points are ordered by nondecreasing timestamp and values are + * finite. Zero points is valid and returns false because no scale exists. + * INVARIANT: a successful scale always has a positive Y span; a one-point + * time span is represented without inventing another observation. + */ +bool trainlog_chart_scale(const TrainlogChartPoint *points, size_t count, + TrainlogChartScale *scale); +bool trainlog_chart_scale_with_options(const TrainlogChartPoint *points, + size_t count, const TrainlogChartOptions *options, + TrainlogChartScale *scale); + +/* Pure coordinate mappings used by the renderer and terminal-free tests. */ +int trainlog_chart_map_x(const TrainlogChartScale *scale, int64_t timestamp, + int logical_width); +int trainlog_chart_map_y(const TrainlogChartScale *scale, double value, + int logical_height); + +/* Braille cells encode a 2x4 logical raster. Out-of-cell coordinates reject. */ +uint8_t trainlog_chart_braille_dot(uint8_t cell, int subpixel_x, + int subpixel_y); +uint32_t trainlog_chart_braille_codepoint(uint8_t cell); + +/** + * Render one complete chart inside a caller-owned Notcurses surface rectangle. + * + * WHY: statistics screens share graph semantics without owning terminal-backend + * details or duplicating scaling/rasterization. CONTRACT: title and unit are + * borrowed for this call; the function creates no persistent terminal state. + * INVARIANT: every write is clipped to rect and the current ncplane geometry. + */ +void trainlog_chart_render(TrainlogSurface *surface, TrainlogChartRect rect, + const TrainlogChartPoint *points, size_t count, + const char *unit, const char *title); + +void trainlog_chart_render_line(TrainlogSurface *surface, TrainlogChartRect rect, + const TrainlogChartPoint *points, size_t count, const char *unit, + const char *title, const TrainlogChartOptions *options); + +/** Render discrete time buckets with one shared Y scale and no interpolation. */ +void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect, + const TrainlogChartPoint *points, size_t count, const char *unit, + const char *title, const TrainlogChartOptions *options); + +#endif diff --git a/tui/include/trainlog/statistics.h b/tui/include/trainlog/statistics.h new file mode 100644 index 0000000..cca75ac --- /dev/null +++ b/tui/include/trainlog/statistics.h @@ -0,0 +1,186 @@ +#ifndef TRAINLOG_STATISTICS_H +#define TRAINLOG_STATISTICS_H + +/** + * @file statistics.h + * @brief Auditable read models for the desktop statistics centre. + */ + +#include +#include +#include + +#include "trainlog/database.h" + +#define TRAINLOG_STATISTICS_BUCKET_MAX 128U +#define TRAINLOG_STATISTICS_ZONE_MAX 64U +#define TRAINLOG_STATISTICS_SERIES_MAX 256U + +typedef enum TrainlogStatisticsWindow { + TRAINLOG_STATISTICS_7_DAYS = 0, + TRAINLOG_STATISTICS_30_DAYS, + TRAINLOG_STATISTICS_ALL +} TrainlogStatisticsWindow; + +typedef enum TrainlogStatisticsBucketKind { + TRAINLOG_STATISTICS_BUCKET_7_DAYS = 0, + TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH +} TrainlogStatisticsBucketKind; + +typedef struct TrainlogStatisticsBucket { + int64_t timestamp; + int64_t end_timestamp; + char label[24]; + size_t sessions; + size_t sets; + uint64_t repetitions; + double loaded_volume_kg; + size_t immediate_feedback_count; +} TrainlogStatisticsBucket; + +typedef struct TrainlogStatisticsSummary { + size_t sessions; + size_t distinct_exercises; + size_t occurrences; + size_t sets; + uint64_t repetitions; + uint64_t activity_duration_seconds; + uint64_t session_duration_seconds; + size_t sessions_with_duration; + double loaded_volume_kg; + size_t active_days; + size_t active_weeks; + int64_t latest_session_timestamp; + size_t days_since_last_session; + size_t immediate_feedback_count; + size_t followup_count; + char top_feedback_exercise[TRAINLOG_NAME_MAX + 1U]; + size_t top_feedback_count; + size_t planned_actual_occurrences; + uint64_t planned_sets; + uint64_t actual_sets_for_plans; + uint64_t planned_repetitions; + uint64_t actual_repetitions_for_plans; + TrainlogStatisticsBucket buckets[TRAINLOG_STATISTICS_BUCKET_MAX]; + size_t bucket_count; + bool buckets_partial; + TrainlogStatisticsBucketKind bucket_kind; +} TrainlogStatisticsSummary; + +typedef struct TrainlogZoneStatistics { + char zone_id[TRAINLOG_ID_MAX + 1U]; + char label[TRAINLOG_NAME_MAX + 1U]; + size_t sessions_any; + size_t primary_7; + size_t secondary_7; + size_t primary_30; + size_t secondary_30; + size_t distinct_exercises; + char latest_primary[TRAINLOG_TIMESTAMP_MAX + 1U]; + char latest_secondary[TRAINLOG_TIMESTAMP_MAX + 1U]; +} TrainlogZoneStatistics; + +typedef enum TrainlogExerciseSeriesKind { + TRAINLOG_EXERCISE_SERIES_LOAD = 0, + TRAINLOG_EXERCISE_SERIES_VOLUME, + TRAINLOG_EXERCISE_SERIES_REPETITIONS, + TRAINLOG_EXERCISE_SERIES_SETS, + TRAINLOG_EXERCISE_SERIES_MAX, + TRAINLOG_EXERCISE_SERIES_COUNT +} TrainlogExerciseSeriesKind; + +typedef struct TrainlogStatisticPoint { + int64_t timestamp; + char timestamp_label[TRAINLOG_TIMESTAMP_MAX + 1U]; + double value; +} TrainlogStatisticPoint; + +typedef struct TrainlogMaxListItem { + char exercise_id[TRAINLOG_ID_MAX + 1U]; + double latest_max_kg; + char latest_at[TRAINLOG_TIMESTAMP_MAX + 1U]; +} TrainlogMaxListItem; + +typedef struct TrainlogExerciseListStatistic { + char exercise_id[TRAINLOG_ID_MAX + 1U]; + size_t occurrences; + char latest_at[TRAINLOG_TIMESTAMP_MAX + 1U]; +} TrainlogExerciseListStatistic; + +typedef struct TrainlogExerciseStatistics { + char exercise_id[TRAINLOG_ID_MAX + 1U]; + char name[TRAINLOG_NAME_MAX + 1U]; + TrainlogTrackingMode tracking_mode; + TrainlogRecordingMode recording_mode; + char load_semantics[32]; + size_t occurrences; + size_t sessions; + size_t sets; + uint64_t repetitions; + uint64_t duration_seconds; + double loaded_volume_kg; + double highest_load_kg; + bool has_highest_load; + int highest_repetitions; + double highest_repetitions_load_kg; + bool has_repetitions_at_load; + char first_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char last_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + size_t frequency_7; + size_t frequency_30; + size_t max_count; + double latest_max_kg; + double previous_max_kg; + double best_max_kg; + char latest_max_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + bool has_latest_max; + bool has_previous_max; + size_t immediate_feedback_count; + uint64_t planned_sets; + uint64_t actual_sets_for_plans; + uint64_t planned_repetitions; + uint64_t actual_repetitions_for_plans; + double latest_planned_weight_kg; + double latest_actual_weight_kg; + bool has_latest_planned_actual_weight; + TrainlogStatisticPoint series[TRAINLOG_EXERCISE_SERIES_COUNT] + [TRAINLOG_STATISTICS_SERIES_MAX]; + size_t series_count[TRAINLOG_EXERCISE_SERIES_COUNT]; + bool series_partial[TRAINLOG_EXERCISE_SERIES_COUNT]; +} TrainlogExerciseStatistics; + +/** + * Load selected-period global statistics and bounded comparison buckets. + * + * CONTRACT: now_utc is the inclusive observation cutoff. Summary totals cover + * the current selected calendar period, while graph buckets compare consecutive + * calendar periods. Seven-day buckets are Monday-Sunday weeks and 30-day mode + * uses Gregorian calendar months; all-history aggregation is adaptive. + * Only the graph series can be bounded/partial. The + * database and output are borrowed and no SQLite resource escapes this call. + */ +TrainlogStatus trainlog_statistics_load_summary( + TrainlogDatabase *database, TrainlogStatisticsWindow window, + int64_t now_utc, TrainlogStatisticsSummary *output); + +/* BODY ZONES are counted only from persisted primary/secondary relations. */ +TrainlogStatus trainlog_statistics_load_zones( + TrainlogDatabase *database, int64_t now_utc, + TrainlogZoneStatistics *output, size_t capacity, size_t *output_count, + bool *output_partial); + +/* Exact canonical exercise_id lookup; display names never select history. */ +TrainlogStatus trainlog_statistics_load_exercise( + TrainlogDatabase *database, const char *exercise_id, int64_t now_utc, + TrainlogExerciseStatistics *output); + +/* One bounded query for the default MAX catalogue surface; newest explicit + * result wins per stable exercise_id and exercises without MAX are absent. */ +TrainlogStatus trainlog_statistics_list_maxima(TrainlogDatabase *database, + TrainlogMaxListItem *output, size_t capacity, size_t *output_count, + bool *output_partial); +TrainlogStatus trainlog_statistics_list_exercises(TrainlogDatabase *database, + TrainlogExerciseListStatistic *output, size_t capacity, + size_t *output_count, bool *output_partial); + +#endif diff --git a/tui/meson.build b/tui/meson.build index f3490a8..67b5a5a 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -98,6 +98,7 @@ trainlog_core_sources = files( 'src/mtp.c', 'src/reps.c', 'src/session_generation.c', + 'src/statistics.c', 'src/measured_max.c', 'src/sync.c', 'src/sync_history.c', @@ -140,6 +141,7 @@ trainlog_core_dep = declare_dependency( trainlog_tui_sources = files( 'src/main.c', 'src/app_shell.c', + 'src/chart.c', 'src/sync_screen_action.c', 'src/terminal.c', 'src/theme.c', @@ -651,6 +653,7 @@ test_tui_workflows = executable( 'test_tui_workflows', 'tests/test_tui_workflows.c', 'src/app_shell.c', + 'src/chart.c', 'src/theme.c', 'src/sync_screen_action.c', dependencies: trainlog_core_dep, @@ -659,6 +662,26 @@ test_tui_workflows = executable( test('tui_workflows', test_tui_workflows) +test_chart = executable( + 'test_chart', + 'tests/test_chart.c', + 'src/chart.c', + include_directories: trainlog_include, + dependencies: m_dep, + c_args: strict_c_args, +) + +test('chart', test_chart) + +test_statistics = executable( + 'test_statistics', + 'tests/test_statistics.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('statistics', test_statistics) + test_feedback_labels = executable( 'test_feedback_labels', 'tests/test_feedback_labels.c', diff --git a/tui/src/app_shell.c b/tui/src/app_shell.c index 9604d00..14f41b6 100644 --- a/tui/src/app_shell.c +++ b/tui/src/app_shell.c @@ -517,7 +517,10 @@ const char *trainlog_route_title(TrainlogAppRoute route) case TRAINLOG_ROUTE_EQUIPMENT: return "Équipements / Catalogue"; case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: return "Équipements / Fiche"; case TRAINLOG_ROUTE_STATS: return "Statistiques"; + case TRAINLOG_ROUTE_STATS_TRAINING: return "Statistiques / Entraînement"; case TRAINLOG_ROUTE_STATS_EXERCISE: return "Statistiques / Par exercice"; + case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: return "Statistiques / Exercice"; + case TRAINLOG_ROUTE_STATS_ZONES: return "Statistiques / Zones"; case TRAINLOG_ROUTE_BODY: return "Statistiques / Mensurations"; case TRAINLOG_ROUTE_BODY_DETAIL: return "Mensurations / Relevé"; case TRAINLOG_ROUTE_BODY_METRIC: return "Mensurations / Historique"; @@ -540,7 +543,10 @@ TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route) if (route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE || route == TRAINLOG_ROUTE_EXERCISE_MAX) return TRAINLOG_ROUTE_STATS; if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) return TRAINLOG_ROUTE_EQUIPMENT; - if (route == TRAINLOG_ROUTE_STATS_EXERCISE || + if (route == TRAINLOG_ROUTE_STATS_TRAINING || + route == TRAINLOG_ROUTE_STATS_EXERCISE || + route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL || + route == TRAINLOG_ROUTE_STATS_ZONES || (route >= TRAINLOG_ROUTE_BODY && route <= TRAINLOG_ROUTE_BODY_ANALYTICS) || route == TRAINLOG_ROUTE_MAX) return TRAINLOG_ROUTE_STATS; return route; diff --git a/tui/src/chart.c b/tui/src/chart.c new file mode 100644 index 0000000..d7fafdf --- /dev/null +++ b/tui/src/chart.c @@ -0,0 +1,425 @@ +/** + * @file chart.c + * @brief Time-scaled high-resolution Braille chart renderer. + */ + +#include "trainlog/chart.h" + +#include +#include +#include +#include +#include +#include +#include + +#define CHART_LABEL_WIDTH 12 +#define CHART_MIN_WIDTH 24 +#define CHART_MIN_HEIGHT 6 + +static int clamp_coordinate(int value, int limit) +{ + if (limit <= 1) return 0; + if (value < 0) return 0; + if (value >= limit) return limit - 1; + return value; +} + +bool trainlog_chart_scale(const TrainlogChartPoint *points, size_t count, + TrainlogChartScale *scale) +{ + return trainlog_chart_scale_with_options(points, count, NULL, scale); +} + +bool trainlog_chart_scale_with_options(const TrainlogChartPoint *points, + size_t count, const TrainlogChartOptions *options, + TrainlogChartScale *scale) +{ + size_t index; + double minimum; + double maximum; + double span; + double margin; + if (scale == NULL || (count > 0U && points == NULL) || count == 0U) + return false; + minimum = points[0].value; + maximum = minimum; + if (!isfinite(minimum)) return false; + for (index = 1U; index < count; ++index) { + if (!isfinite(points[index].value) || + points[index].timestamp < points[index - 1U].timestamp) + return false; + if (points[index].value < minimum) minimum = points[index].value; + if (points[index].value > maximum) maximum = points[index].value; + } + span = maximum - minimum; + /* WHY: a modest 8% headroom keeps observations off the frame while the + * labelled scale prevents a visually amplified small change from hiding + * its actual magnitude. Constant series use 2% of their value (at least + * one unit total) solely to make their horizontal position defined. */ + if (span > 0.0) margin = span * 0.08; + else { + margin = fabs(minimum) * 0.02; + if (margin < 0.5) margin = 0.5; + } + if (!isfinite(margin) || minimum < -DBL_MAX + margin || + maximum > DBL_MAX - margin) return false; + scale->minimum_timestamp = options != NULL && options->has_time_domain + ? options->minimum_timestamp : points[0].timestamp; + scale->maximum_timestamp = options != NULL && options->has_time_domain + ? options->maximum_timestamp : points[count - 1U].timestamp; + if (scale->maximum_timestamp < scale->minimum_timestamp) return false; + scale->minimum_value = options != NULL && options->zero_baseline + ? 0.0 : minimum - margin; + scale->maximum_value = maximum + margin; + if (options != NULL && options->non_negative && scale->minimum_value < 0.0) + scale->minimum_value = 0.0; + if (scale->maximum_value <= scale->minimum_value) + scale->maximum_value = scale->minimum_value + (maximum > 0.0 ? maximum * 0.08 : 1.0); + return true; +} + +int trainlog_chart_map_x(const TrainlogChartScale *scale, int64_t timestamp, + int logical_width) +{ + long double ratio; + if (scale == NULL || logical_width <= 1 || + scale->maximum_timestamp <= scale->minimum_timestamp) + return logical_width > 1 ? (logical_width - 1) / 2 : 0; + ratio = ((long double)timestamp - (long double)scale->minimum_timestamp) / + ((long double)scale->maximum_timestamp - + (long double)scale->minimum_timestamp); + return clamp_coordinate((int)llroundl(ratio * (logical_width - 1)), + logical_width); +} + +int trainlog_chart_map_y(const TrainlogChartScale *scale, double value, + int logical_height) +{ + double ratio; + if (scale == NULL || logical_height <= 1 || + scale->maximum_value <= scale->minimum_value) + return logical_height > 1 ? (logical_height - 1) / 2 : 0; + ratio = (scale->maximum_value - value) / + (scale->maximum_value - scale->minimum_value); + return clamp_coordinate((int)llround(ratio * (logical_height - 1)), + logical_height); +} + +uint8_t trainlog_chart_braille_dot(uint8_t cell, int subpixel_x, + int subpixel_y) +{ + static const uint8_t dots[4][2] = { + {0x01U, 0x08U}, {0x02U, 0x10U}, {0x04U, 0x20U}, {0x40U, 0x80U} + }; + if (subpixel_x < 0 || subpixel_x > 1 || + subpixel_y < 0 || subpixel_y > 3) return cell; + return (uint8_t)(cell | dots[subpixel_y][subpixel_x]); +} + +uint32_t trainlog_chart_braille_codepoint(uint8_t cell) +{ + return 0x2800U + (uint32_t)cell; +} + +static void raster_dot(uint8_t *cells, int cell_width, int cell_height, + int x, int y) +{ + int cell_x; + int cell_y; + if (cells == NULL || x < 0 || y < 0 || x >= cell_width * 2 || + y >= cell_height * 4) return; + cell_x = x / 2; + cell_y = y / 4; + cells[(size_t)cell_y * (size_t)cell_width + (size_t)cell_x] = + trainlog_chart_braille_dot( + cells[(size_t)cell_y * (size_t)cell_width + (size_t)cell_x], + x % 2, y % 4); +} + +static void raster_line(uint8_t *cells, int cell_width, int cell_height, + int x0, int y0, int x1, int y1) +{ + int64_t dx = llabs((int64_t)x1 - (int64_t)x0); + int sx = x0 < x1 ? 1 : -1; + int64_t dy = -llabs((int64_t)y1 - (int64_t)y0); + int sy = y0 < y1 ? 1 : -1; + int64_t error = dx + dy; + for (;;) { + raster_dot(cells, cell_width, cell_height, x0, y0); + if (x0 == x1 && y0 == y1) break; + { + int64_t doubled = error * INT64_C(2); + if (doubled >= dy) { error += dy; x0 += sx; } + if (doubled <= dx) { error += dx; y0 += sy; } + } + } +} + +static void short_date(const TrainlogChartPoint *point, char output[6]) +{ + time_t seconds; + struct tm value; + if (point != NULL && point->timestamp_label != NULL && + strlen(point->timestamp_label) >= 10U && + point->timestamp_label[4] == '-' && point->timestamp_label[7] == '-') { + output[0] = point->timestamp_label[8]; + output[1] = point->timestamp_label[9]; + output[2] = '/'; + output[3] = point->timestamp_label[5]; + output[4] = point->timestamp_label[6]; + output[5] = '\0'; + return; + } + seconds = point != NULL ? (time_t)point->timestamp : (time_t)-1; + if (point == NULL || (int64_t)seconds != point->timestamp || + gmtime_r(&seconds, &value) == NULL || + strftime(output, 6U, "%d/%m", &value) != 5U) + (void)snprintf(output, 6U, "--/--"); +} + +static void render_empty(TrainlogSurface *surface, TrainlogChartRect rect, + const char *title) +{ + trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + trainlog_surface_printf(surface, rect.top, rect.left, "%s", + title != NULL ? title : "ÉVOLUTION DANS LE TEMPS"); + trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (rect.height > 2) + trainlog_surface_printf(surface, rect.top + 2, rect.left, + "Aucune donnée disponible."); +} + +void trainlog_chart_render(TrainlogSurface *surface, TrainlogChartRect rect, + const TrainlogChartPoint *points, size_t count, + const char *unit, const char *title) +{ + trainlog_chart_render_line(surface, rect, points, count, unit, title, NULL); +} + +void trainlog_chart_render_line(TrainlogSurface *surface, TrainlogChartRect rect, + const TrainlogChartPoint *points, size_t count, + const char *unit, const char *title, + const TrainlogChartOptions *options) +{ + TrainlogChartScale scale; + uint8_t *cells; + size_t cell_count; + int plot_left; + int plot_top; + int plot_width; + int plot_height; + int logical_width; + int logical_height; + size_t index; + char first_date[6]; + char last_date[6]; + const char *safe_unit = unit != NULL ? unit : ""; + if (surface == NULL || rect.top < 0 || rect.left < 0 || + rect.height <= 0 || rect.width <= 0 || + rect.top > INT_MAX - rect.height || + rect.left > INT_MAX - rect.width) return; + if (count == 0U) { render_empty(surface, rect, title); return; } + if (!trainlog_chart_scale_with_options(points, count, options, &scale)) { + render_empty(surface, rect, title); + return; + } + trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + trainlog_surface_printf(surface, rect.top, rect.left, "%s", + title != NULL ? title : "ÉVOLUTION DANS LE TEMPS"); + if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) { + trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + if (count == 1U) + trainlog_surface_printf(surface, + rect.top + (rect.height > 2 ? 2 : 1), rect.left, + "◆ %.2f %s", points[0].value, safe_unit); + else + trainlog_surface_printf(surface, + rect.top + (rect.height > 2 ? 2 : 1), rect.left, + "◆ %zu relevés · %.2f %s", count, + points[count - 1U].value, safe_unit); + return; + } + plot_left = rect.left + CHART_LABEL_WIDTH; + plot_top = rect.top + 2; + plot_width = rect.width - CHART_LABEL_WIDTH - 1; + plot_height = rect.height - 5; + if (plot_width <= 0 || plot_height <= 0 || + plot_width > INT_MAX / 2 || plot_height > INT_MAX / 4 || + (size_t)plot_width > SIZE_MAX / (size_t)plot_height) return; + cell_count = (size_t)plot_width * (size_t)plot_height; + cells = calloc(cell_count, sizeof(*cells)); + if (cells == NULL) return; + logical_width = plot_width * 2; + logical_height = plot_height * 4; + for (index = 1U; index < count; ++index) { + raster_line(cells, plot_width, plot_height, + trainlog_chart_map_x(&scale, points[index - 1U].timestamp, + logical_width), + trainlog_chart_map_y(&scale, points[index - 1U].value, + logical_height), + trainlog_chart_map_x(&scale, points[index].timestamp, logical_width), + trainlog_chart_map_y(&scale, points[index].value, logical_height)); + } + if (count == 1U) + raster_dot(cells, plot_width, plot_height, + trainlog_chart_map_x(&scale, points[0].timestamp, logical_width), + trainlog_chart_map_y(&scale, points[0].value, logical_height)); + + trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(surface, plot_top, rect.left, "%8.2f %s", + scale.maximum_value, safe_unit); + trainlog_surface_printf(surface, plot_top + plot_height - 1, rect.left, + "%8.2f %s", scale.minimum_value, safe_unit); + for (int row = 0; row < plot_height; ++row) + trainlog_surface_draw(surface, plot_top + row, plot_left - 1, 0x2502U); + trainlog_surface_draw(surface, plot_top + plot_height, plot_left - 1, 0x2514U); + for (int column = 0; column < plot_width; ++column) + trainlog_surface_draw(surface, plot_top + plot_height, + plot_left + column, 0x2500U); + + trainlog_surface_set_role(surface, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (int row = 0; row < plot_height; ++row) + for (int column = 0; column < plot_width; ++column) { + uint8_t cell = cells[(size_t)row * (size_t)plot_width + + (size_t)column]; + if (cell != 0U) + trainlog_surface_draw(surface, plot_top + row, + plot_left + column, trainlog_chart_braille_codepoint(cell)); + } + free(cells); + + /* CONTRACT: diamonds identify stored observations, while Braille dots + * between them are interpolation only. Several observations may map to one + * cell after clipping; this never changes or fabricates source values. */ + trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + for (index = 0U; index < count; ++index) { + int logical_x = trainlog_chart_map_x(&scale, points[index].timestamp, + logical_width); + int logical_y = trainlog_chart_map_y(&scale, points[index].value, + logical_height); + trainlog_surface_draw(surface, plot_top + logical_y / 4, + plot_left + logical_x / 2, 0x25c6U); + } + { + TrainlogChartPoint first_domain = {scale.minimum_timestamp, 0.0, NULL}; + TrainlogChartPoint last_domain = {scale.maximum_timestamp, 0.0, NULL}; + short_date(options != NULL && options->has_time_domain + ? &first_domain : &points[0], first_date); + short_date(options != NULL && options->has_time_domain + ? &last_domain : &points[count - 1U], last_date); + } + trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (count == 1U) { + trainlog_surface_printf(surface, plot_top + plot_height + 1, + plot_left + (plot_width - 5) / 2, "%s", first_date); + } else { + trainlog_surface_printf(surface, plot_top + plot_height + 1, + plot_left, "%s", first_date); + trainlog_surface_printf(surface, plot_top + plot_height + 1, + plot_left + plot_width - 5, "%s", last_date); + } + if (count > 2U && plot_width >= 34) { + size_t middle = count / 2U; + int column = plot_left + trainlog_chart_map_x(&scale, + points[middle].timestamp, logical_width) / 2; + short_date(&points[middle], first_date); + if (column > plot_left + 6 && column < plot_left + plot_width - 10) { + trainlog_surface_draw(surface, plot_top + plot_height, + column, 0x252cU); + trainlog_surface_printf(surface, plot_top + plot_height + 1, + column - 2, "%s", first_date); + } + } + trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect, + const TrainlogChartPoint *points, size_t count, const char *unit, + const char *title, const TrainlogChartOptions *options) +{ + TrainlogChartScale scale; + int plot_left, plot_top, plot_width, plot_height; + size_t index; + const char *safe_unit = unit != NULL ? unit : ""; + if (surface == NULL || rect.height <= 0 || rect.width <= 0) return; + if (count == 0U) { render_empty(surface, rect, title); return; } + if (!trainlog_chart_scale_with_options(points, count, options, &scale)) { + render_empty(surface, rect, title); return; + } + trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + trainlog_surface_printf(surface, rect.top, rect.left, "%s", + title != NULL ? title : "TOTAUX PAR PÉRIODE"); + if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) { + trainlog_surface_printf(surface, rect.top + 2, rect.left, + "%zu périodes · %.2f %s", count, points[count - 1U].value, safe_unit); + return; + } + plot_left = rect.left + CHART_LABEL_WIDTH; + plot_top = rect.top + 2; + plot_width = rect.width - CHART_LABEL_WIDTH - 1; + plot_height = rect.height - 5; + trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(surface, plot_top, rect.left, "%8.2f %s", + scale.maximum_value, safe_unit); + trainlog_surface_printf(surface, plot_top + plot_height - 1, rect.left, + "%8.2f %s", scale.minimum_value, safe_unit); + for (int row = 0; row < plot_height; ++row) + trainlog_surface_draw(surface, plot_top + row, plot_left - 1, 0x2502U); + for (int column = 0; column < plot_width; ++column) + trainlog_surface_draw(surface, plot_top + plot_height, + plot_left + column, 0x2500U); + trainlog_surface_set_role(surface, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (index = 0U; index < count; ++index) { + int center = trainlog_chart_map_x(&scale, points[index].timestamp, plot_width); + int top_row = trainlog_chart_map_y(&scale, points[index].value, plot_height); + int half = count > 0U ? plot_width / (int)(count * 3U) : 0; + if (points[index].value <= scale.minimum_value) continue; + if (half < 0) half = 0; + if (half > 2) half = 2; + for (int row = top_row; row < plot_height; ++row) + for (int column = center - half; column <= center + half; ++column) + if (column >= 0 && column < plot_width) + trainlog_surface_draw(surface, plot_top + row, + plot_left + column, 0x2588U); + } + { + char first[6], last[6]; + TrainlogChartPoint first_domain = {scale.minimum_timestamp, 0.0, NULL}; + TrainlogChartPoint last_domain = {scale.maximum_timestamp, 0.0, NULL}; + trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (count <= 8U && plot_width / (int)count >= 12) { + for (index = 0U; index < count; ++index) { + int center = trainlog_chart_map_x(&scale, + points[index].timestamp, plot_width); + int column = plot_left + center - 5; + const char *label = points[index].timestamp_label; + if (column < plot_left) column = plot_left; + if (column > plot_left + plot_width - 11) + column = plot_left + plot_width - 11; + if (label != NULL) + trainlog_surface_printf(surface, + plot_top + plot_height + 1, column, "%.11s", label); + } + } else { + short_date(&first_domain, first); short_date(&last_domain, last); + trainlog_surface_printf(surface, plot_top + plot_height + 1, + plot_left, "%s", first); + trainlog_surface_printf(surface, plot_top + plot_height + 1, + plot_left + plot_width - 5, "%s", last); + } + } +} diff --git a/tui/src/statistics.c b/tui/src/statistics.c new file mode 100644 index 0000000..b30cd31 --- /dev/null +++ b/tui/src/statistics.c @@ -0,0 +1,859 @@ +/** + * @file statistics.c + * @brief Read-only SQLite aggregation for auditable TUI statistics. + */ + +#include "trainlog/statistics.h" + +#include +#include +#include +#include + +#include "database_internal.h" +#include "timestamp.h" + +#define SECONDS_PER_DAY INT64_C(86400) +#define SECONDS_PER_WEEK (INT64_C(7) * SECONDS_PER_DAY) + +static TrainlogStatus prepare(sqlite3 *database, const char *sql, + sqlite3_stmt **statement) +{ + return database != NULL && sql != NULL && statement != NULL && + sqlite3_prepare_v2(database, sql, -1, statement, NULL) == SQLITE_OK + ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +} + +static bool size_column(sqlite3_stmt *statement, int column, size_t *output) +{ + sqlite3_int64 value = sqlite3_column_int64(statement, column); + if (value < 0 || (uint64_t)value > (uint64_t)SIZE_MAX) return false; + *output = (size_t)value; + return true; +} + +static bool u64_column(sqlite3_stmt *statement, int column, uint64_t *output) +{ + sqlite3_int64 value = sqlite3_column_int64(statement, column); + if (value < 0) return false; + *output = (uint64_t)value; + return true; +} + +static bool copy_text(sqlite3_stmt *statement, int column, char *output, + size_t capacity) +{ + const unsigned char *value = sqlite3_column_text(statement, column); + int bytes = sqlite3_column_bytes(statement, column); + if (output == NULL || capacity == 0U || value == NULL || bytes < 0 || + (size_t)bytes >= capacity) return false; + (void)memcpy(output, value, (size_t)bytes); + output[bytes] = '\0'; + return true; +} + +static TrainlogStatus bind_window(sqlite3_stmt *statement, int64_t start, + int64_t now) +{ + if (sqlite3_bind_int64(statement, 1, start) != SQLITE_OK || + sqlite3_bind_int64(statement, 2, now) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + return TRAINLOG_STATUS_OK; +} + +static bool local_date_timestamp(const char *date, int64_t *output) +{ + char timestamp[32]; + TrainlogTimestampKey key; + TrainlogTimestampKey unix_epoch; + int written; + if (date == NULL || output == NULL || strlen(date) != 10U) return false; + written = snprintf(timestamp, sizeof(timestamp), "%sT00:00:00Z", date); + if (written != 20 || !trainlog_timestamp_parse(timestamp, 20U, &key) || + !trainlog_timestamp_parse("1970-01-01T00:00:00Z", 20U, &unix_epoch)) + return false; + /* TrainlogTimestampKey uses a proleptic year-1 epoch for ordering. Chart + * coordinates use Unix seconds, so the epochs must never be conflated. */ + *output = key.utc_second - unix_epoch.utc_second; + return true; +} + +static bool current_local_week_timestamp(int64_t now, int64_t *output) +{ + time_t seconds = (time_t)now; + struct tm local; + char date[11]; + int offset; + if ((int64_t)seconds != now || localtime_r(&seconds, &local) == NULL) return false; + offset = (local.tm_wday + 6) % 7; + local.tm_mday -= offset; + local.tm_hour = 12; local.tm_min = 0; local.tm_sec = 0; + if (mktime(&local) == (time_t)-1 || strftime(date, sizeof(date), "%Y-%m-%d", &local) != 10U) + return false; + return local_date_timestamp(date, output); +} + +static bool current_local_date(int64_t now, char output[11], + int64_t *timestamp) +{ + time_t seconds = (time_t)now; + struct tm local; + if ((int64_t)seconds != now || localtime_r(&seconds, &local) == NULL || + strftime(output, 11U, "%Y-%m-%d", &local) != 10U) + return false; + return local_date_timestamp(output, timestamp); +} + +static bool calendar_window_start(TrainlogStatisticsWindow window, + int64_t now, int64_t *output) +{ + char date[11]; + int64_t ignored; + if (output == NULL) return false; + if (window == TRAINLOG_STATISTICS_ALL) { + *output = INT64_MIN; + return true; + } + if (window == TRAINLOG_STATISTICS_7_DAYS) + return current_local_week_timestamp(now, output); + if (!current_local_date(now, date, &ignored)) return false; + date[8] = '0'; + date[9] = '1'; + return local_date_timestamp(date, output); +} + +static const char *actual_occurrences_cte(void) +{ + return "WITH actual_occurrences AS (" + "SELECT se.id,se.session_row_id,se.exercise_row_id,se.entry_id," + "se.load_mode,se.target_sets,se.target_reps,se.target_weight_kg " + "FROM session_exercises se WHERE EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) " + "OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) " + "OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id))," + "actual_sessions AS (SELECT DISTINCT s.id,s.session_id,s.started_at,s.ended_at " + "FROM sessions s JOIN actual_occurrences ao ON ao.session_row_id=s.id) "; +} + +static TrainlogStatus load_summary_row(TrainlogDatabase *database, + int64_t start, int64_t now, TrainlogStatisticsSummary *output) +{ + sqlite3_stmt *statement = NULL; + char sql[8192]; + int written = snprintf(sql, sizeof(sql), + "%s SELECT " + "(SELECT COUNT(*) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(DISTINCT ao.exercise_row_id) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(*) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(*) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM(p.reps),0) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE p.reps IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM(COALESCE(p.duration_seconds,0)),0) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2) + " + "(SELECT COALESCE(SUM(c.duration_seconds),0) FROM continuous_activity c JOIN actual_occurrences ao ON ao.id=c.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM(unixepoch(s.ended_at)-unixepoch(s.started_at)),0) FROM actual_sessions s WHERE s.ended_at IS NOT NULL AND unixepoch(s.ended_at)>=unixepoch(s.started_at) AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(*) FROM actual_sessions s WHERE s.ended_at IS NOT NULL AND unixepoch(s.ended_at)>=unixepoch(s.started_at) AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM(p.reps*p.weight_kg),0.0) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id JOIN exercises e ON e.id=ao.exercise_row_id WHERE p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(DISTINCT date(s.started_at)) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(DISTINCT CAST(unixepoch(s.started_at)/604800 AS INTEGER)) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(MAX(unixepoch(s.started_at)),0) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(*) FROM session_followups f JOIN sessions s ON s.id=f.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COUNT(*) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM(ao.target_sets),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM((SELECT COUNT(*) FROM performed_sets p WHERE p.session_exercise_row_id=ao.id)),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM(ao.target_sets*ao.target_reps),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND ao.target_reps IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)," + "(SELECT COALESCE(SUM((SELECT COALESCE(SUM(p.reps),0) FROM performed_sets p WHERE p.session_exercise_row_id=ao.id)),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND ao.target_reps IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)", + actual_occurrences_cte()); + if (written < 0 || (size_t)written >= sizeof(sql) || + prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + bind_window(statement, start, now) != TRAINLOG_STATUS_OK) goto fail; + if (sqlite3_step(statement) != SQLITE_ROW || + !size_column(statement, 0, &output->sessions) || + !size_column(statement, 1, &output->distinct_exercises) || + !size_column(statement, 2, &output->occurrences) || + !size_column(statement, 3, &output->sets) || + !u64_column(statement, 4, &output->repetitions) || + !u64_column(statement, 5, &output->activity_duration_seconds) || + !u64_column(statement, 6, &output->session_duration_seconds) || + !size_column(statement, 7, &output->sessions_with_duration) || + !isfinite(sqlite3_column_double(statement, 8)) || + !size_column(statement, 9, &output->active_days) || + !size_column(statement, 10, &output->active_weeks) || + !size_column(statement, 12, &output->immediate_feedback_count) || + !size_column(statement, 13, &output->followup_count) || + !size_column(statement, 14, &output->planned_actual_occurrences) || + !u64_column(statement, 15, &output->planned_sets) || + !u64_column(statement, 16, &output->actual_sets_for_plans) || + !u64_column(statement, 17, &output->planned_repetitions) || + !u64_column(statement, 18, &output->actual_repetitions_for_plans)) + goto fail; + output->loaded_volume_kg = sqlite3_column_double(statement, 8); + output->latest_session_timestamp = sqlite3_column_int64(statement, 11); + if (sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + statement = NULL; + return TRAINLOG_STATUS_OK; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +static bool month_key_parse(const char *month, int64_t *key) +{ + int year; + int number; + char trailing; + if (month == NULL || key == NULL || + sscanf(month, "%d-%d%c", &year, &number, &trailing) != 2 || + year < 1 || number < 1 || number > 12) + return false; + *key = (int64_t)year * INT64_C(12) + (int64_t)(number - 1); + return true; +} + +static bool month_key_date(int64_t key, char output[11]) +{ + int64_t year; + int64_t month; + int written; + if (key < INT64_C(12)) return false; + year = key / INT64_C(12); + month = key % INT64_C(12) + 1; + if (year > 9999) return false; + written = snprintf(output, 11U, "%04lld-%02lld-01", + (long long)year, (long long)month); + return written == 10; +} + +static bool format_month_label(int64_t key, char *output, size_t capacity) +{ + static const char *const names[12] = { + "janv.", "févr.", "mars", "avr.", "mai", "juin", + "juil.", "août", "sept.", "oct.", "nov.", "déc." + }; + int64_t year = key / INT64_C(12); + int64_t month = key % INT64_C(12); + int written; + if (output == NULL || capacity == 0U || month < 0 || month >= 12 || + year < 1 || year > 9999) return false; + written = snprintf(output, capacity, "%s %04lld", names[month], + (long long)year); + return written >= 0 && (size_t)written < capacity; +} + +static TrainlogStatus load_month_buckets(TrainlogDatabase *database, + int64_t now, bool recent_only, TrainlogStatisticsSummary *output) +{ + sqlite3_stmt *statement = NULL; + TrainlogStatisticsBucket active[TRAINLOG_STATISTICS_BUCKET_MAX]; + int64_t active_keys[TRAINLOG_STATISTICS_BUCKET_MAX]; + size_t active_count = 0U; + int64_t current_key; + int64_t first_key = INT64_MAX; + char current_date[11]; + char current_month[8]; + int64_t ignored_timestamp; + char sql[4096]; + int written = snprintf(sql, sizeof(sql), + "%s, months AS (SELECT substr(s.started_at,1,7) month," + "COUNT(DISTINCT s.id) sessions,COUNT(p.id) sets,COALESCE(SUM(p.reps),0) reps," + "COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0) volume " + "FROM actual_sessions s LEFT JOIN actual_occurrences ao ON ao.session_row_id=s.id " + "LEFT JOIN exercises e ON e.id=ao.exercise_row_id LEFT JOIN performed_sets p ON p.session_exercise_row_id=ao.id " + "WHERE unixepoch(s.started_at)<=?1 GROUP BY month) " + "SELECT month,sessions,sets,reps,volume," + "(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises sf ON sf.id=f.session_exercise_row_id JOIN sessions ss ON ss.id=sf.session_row_id WHERE substr(ss.started_at,1,7)=months.month AND unixepoch(ss.started_at)<=?1) " + "FROM months ORDER BY month DESC LIMIT %u;", + actual_occurrences_cte(), TRAINLOG_STATISTICS_BUCKET_MAX + 1U); + int step; + if (!current_local_date(now, current_date, &ignored_timestamp)) + return TRAINLOG_STATUS_DATABASE_ERROR; + (void)memcpy(current_month, current_date, 7U); + current_month[7] = '\0'; + if (!month_key_parse(current_month, ¤t_key)) + return TRAINLOG_STATUS_DATABASE_ERROR; + if (written < 0 || (size_t)written >= sizeof(sql) || + prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_int64(statement, 1, now) != SQLITE_OK) goto fail; + output->buckets_partial = false; + output->bucket_kind = TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogStatisticsBucket *bucket; + const char *month = (const char *)sqlite3_column_text(statement, 0); + int64_t key; + if (!month_key_parse(month, &key)) goto fail; + if (recent_only && (key < current_key - 3 || key > current_key)) + continue; + if (active_count == TRAINLOG_STATISTICS_BUCKET_MAX) { + output->buckets_partial = true; continue; + } + bucket = &active[active_count]; + (void)memset(bucket, 0, sizeof(*bucket)); + active_keys[active_count++] = key; + if (!size_column(statement, 1, &bucket->sessions) || + !size_column(statement, 2, &bucket->sets) || + !u64_column(statement, 3, &bucket->repetitions) || + !isfinite(sqlite3_column_double(statement, 4)) || + !size_column(statement, 5, &bucket->immediate_feedback_count)) goto fail; + bucket->loaded_volume_kg = sqlite3_column_double(statement, 4); + if (key < first_key) first_key = key; + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + output->bucket_count = 0U; + if (first_key == INT64_MAX) return TRAINLOG_STATUS_OK; + if (recent_only && first_key < current_key - 3) first_key = current_key - 3; + if (current_key - first_key + 1 > + (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) { + first_key = current_key - + ((int64_t)TRAINLOG_STATISTICS_BUCKET_MAX - 1); + output->buckets_partial = true; + } + /* INVARIANT: Gregorian month keys are consecutive and non-overlapping. + * Filling every key preserves recorded inactivity and naturally handles + * February, leap years and year boundaries without day arithmetic. */ + for (int64_t key = first_key; key <= current_key; ++key) { + TrainlogStatisticsBucket *bucket = + &output->buckets[output->bucket_count++]; + char start_date[11]; + char end_date[11]; + (void)memset(bucket, 0, sizeof(*bucket)); + if (!month_key_date(key, start_date) || + !month_key_date(key + 1, end_date) || + !local_date_timestamp(start_date, &bucket->timestamp) || + !local_date_timestamp(end_date, &bucket->end_timestamp) || + !format_month_label(key, bucket->label, sizeof(bucket->label))) + goto fail; + for (size_t index = 0U; index < active_count; ++index) + if (active_keys[index] == key) { + int64_t timestamp = bucket->timestamp; + int64_t end_timestamp = bucket->end_timestamp; + char label[sizeof(bucket->label)]; + (void)memcpy(label, bucket->label, sizeof(label)); + *bucket = active[index]; + bucket->timestamp = timestamp; + bucket->end_timestamp = end_timestamp; + (void)memcpy(bucket->label, label, sizeof(label)); + break; + } + } + return TRAINLOG_STATUS_OK; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +static TrainlogStatus load_buckets(TrainlogDatabase *database, + TrainlogStatisticsWindow window, int64_t now, + TrainlogStatisticsSummary *output) +{ + sqlite3_stmt *statement = NULL; + if (window == TRAINLOG_STATISTICS_30_DAYS) + return load_month_buckets(database, now, true, output); + output->bucket_kind = TRAINLOG_STATISTICS_BUCKET_7_DAYS; + char sql[4096]; + int written = snprintf(sql, sizeof(sql), + "%s, weeks AS (SELECT date(substr(s.started_at,1,10),printf('-%%d days',(CAST(strftime('%%w',substr(s.started_at,1,10)) AS INTEGER)+6)%%7)) week," + "COUNT(DISTINCT s.id) sessions,COUNT(p.id) sets,COALESCE(SUM(p.reps),0) reps," + "COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0) volume " + "FROM actual_sessions s LEFT JOIN actual_occurrences ao ON ao.session_row_id=s.id " + "LEFT JOIN exercises e ON e.id=ao.exercise_row_id LEFT JOIN performed_sets p ON p.session_exercise_row_id=ao.id " + "WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2 GROUP BY week) " + "SELECT week,sessions,sets,reps,volume," + "(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises sf ON sf.id=f.session_exercise_row_id JOIN sessions ss ON ss.id=sf.session_row_id WHERE date(substr(ss.started_at,1,10),printf('-%%d days',(CAST(strftime('%%w',substr(ss.started_at,1,10)) AS INTEGER)+6)%%7))=weeks.week AND unixepoch(ss.started_at) BETWEEN ?1 AND ?2) " + "FROM weeks ORDER BY week DESC LIMIT %u;", + actual_occurrences_cte(), TRAINLOG_STATISTICS_BUCKET_MAX + 1U); + if (written < 0 || (size_t)written >= sizeof(sql) || + prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + bind_window(statement, INT64_MIN, now) != TRAINLOG_STATUS_OK) goto fail; + int step; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogStatisticsBucket *bucket; + const char *week; + time_t seconds; + struct tm date; + if (output->bucket_count == TRAINLOG_STATISTICS_BUCKET_MAX) { + output->buckets_partial = true; + break; + } + bucket = &output->buckets[output->bucket_count++]; + week = (const char *)sqlite3_column_text(statement, 0); + if (!local_date_timestamp(week, &bucket->timestamp) || + !size_column(statement, 1, &bucket->sessions) || + !size_column(statement, 2, &bucket->sets) || + !u64_column(statement, 3, &bucket->repetitions) || + !isfinite(sqlite3_column_double(statement, 4)) || + !size_column(statement, 5, &bucket->immediate_feedback_count)) goto fail; + bucket->loaded_volume_kg = sqlite3_column_double(statement, 4); + seconds = (time_t)bucket->timestamp; + if ((int64_t)seconds != bucket->timestamp || + gmtime_r(&seconds, &date) == NULL || + strftime(bucket->label, sizeof(bucket->label), "%d/%m", &date) == 0U) + (void)snprintf(bucket->label, sizeof(bucket->label), "semaine"); + } + if (step != SQLITE_DONE && !output->buckets_partial) goto fail; + if (sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + statement = NULL; + /* SQL reads newest-first for bounded history; charts require chronology. */ + for (size_t left = 0U, right = output->bucket_count; + left < right && left < --right; ++left) { + TrainlogStatisticsBucket copy = output->buckets[left]; + output->buckets[left] = output->buckets[right]; + output->buckets[right] = copy; + } + /* WHY: active-row count alone misses sparse but very long histories. The + * all-history chart chooses one scale from total duration, so it neither + * drops an old sparse bucket nor mixes bucket sizes in a single graph. */ + if (window == TRAINLOG_STATISTICS_ALL && output->bucket_count > 0U) { + int64_t current_week; + int64_t week_span; + if (!current_local_week_timestamp(now, ¤t_week)) goto fail; + week_span = (current_week - output->buckets[0].timestamp) / + SECONDS_PER_WEEK + 1; + if (output->buckets_partial || + week_span > (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) + return load_month_buckets(database, now, false, output); + } + if (output->bucket_count > 0U) { + TrainlogStatisticsBucket filled[TRAINLOG_STATISTICS_BUCKET_MAX]; + int64_t first_week = output->buckets[0].timestamp; + int64_t last_week; + int64_t span; + size_t filled_count = 0U; + size_t source = 0U; + if (!current_local_week_timestamp(now, &last_week)) goto fail; + if (window == TRAINLOG_STATISTICS_7_DAYS) { + int64_t comparison_start = last_week - INT64_C(3) * SECONDS_PER_WEEK; + if (first_week < comparison_start) first_week = comparison_start; + } + span = (last_week - first_week) / SECONDS_PER_WEEK + 1; + if (span > (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) { + first_week = last_week - + ((int64_t)TRAINLOG_STATISTICS_BUCKET_MAX - 1) * SECONDS_PER_WEEK; + output->buckets_partial = true; + } + /* INVARIANT: weekly facts include explicit zero weeks. Connecting only + * active weeks would visually invent activity inside a chronological gap. */ + for (int64_t week = first_week; week <= last_week; + week += SECONDS_PER_WEEK) { + int64_t timestamp = week; + time_t seconds = (time_t)timestamp; + struct tm date; + while (source < output->bucket_count && + output->buckets[source].timestamp < timestamp) ++source; + (void)memset(&filled[filled_count], 0, sizeof(filled[filled_count])); + filled[filled_count].timestamp = timestamp; + filled[filled_count].end_timestamp = timestamp + SECONDS_PER_WEEK; + if (source < output->bucket_count && + output->buckets[source].timestamp == timestamp) { + int64_t end_timestamp = filled[filled_count].end_timestamp; + filled[filled_count] = output->buckets[source]; + filled[filled_count].end_timestamp = end_timestamp; + } + if ((int64_t)seconds == timestamp && gmtime_r(&seconds, &date) != NULL) { + char start_label[6]; + char end_label[6]; + time_t end_seconds = seconds + (time_t)(INT64_C(6) * SECONDS_PER_DAY); + struct tm end; + if (gmtime_r(&end_seconds, &end) != NULL && + strftime(start_label, sizeof(start_label), "%d/%m", &date) == 5U && + strftime(end_label, sizeof(end_label), "%d/%m", &end) == 5U) + (void)snprintf(filled[filled_count].label, + sizeof(filled[filled_count].label), "%s-%s", + start_label, end_label); + } + ++filled_count; + } + (void)memcpy(output->buckets, filled, + filled_count * sizeof(output->buckets[0])); + output->bucket_count = filled_count; + } + return TRAINLOG_STATUS_OK; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +static TrainlogStatus load_top_feedback(TrainlogDatabase *database, + int64_t start, int64_t now, TrainlogStatisticsSummary *output) +{ + sqlite3_stmt *statement = NULL; + const char *sql = + "SELECT e.name,COUNT(*) amount FROM exercise_feedback f " + "JOIN session_exercises se ON se.id=f.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2 " + "GROUP BY e.id ORDER BY amount DESC,e.name COLLATE NOCASE,e.exercise_id LIMIT 1;"; + int step; + if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + bind_window(statement, start, now) != TRAINLOG_STATUS_OK) goto fail; + step = sqlite3_step(statement); + if (step == SQLITE_ROW && + (!copy_text(statement, 0, output->top_feedback_exercise, + sizeof(output->top_feedback_exercise)) || + !size_column(statement, 1, &output->top_feedback_count))) goto fail; + if (step != SQLITE_ROW && step != SQLITE_DONE) goto fail; + if (sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + return TRAINLOG_STATUS_OK; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_statistics_load_summary(TrainlogDatabase *database, + TrainlogStatisticsWindow window, int64_t now_utc, + TrainlogStatisticsSummary *output) +{ + TrainlogStatus status; + int64_t start; + if (database == NULL || database->connection == NULL || output == NULL || + window < TRAINLOG_STATISTICS_7_DAYS || window > TRAINLOG_STATISTICS_ALL) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + (void)memset(output, 0, sizeof(*output)); + if (!calendar_window_start(window, now_utc, &start)) + return TRAINLOG_STATUS_DATABASE_ERROR; + status = trainlog_database_read_snapshot_begin(database); + if (status != TRAINLOG_STATUS_OK) return status; + status = load_summary_row(database, start, now_utc, output); + if (status == TRAINLOG_STATUS_OK && output->latest_session_timestamp > 0 && + output->latest_session_timestamp <= now_utc) { + uint64_t days = (uint64_t)(now_utc - output->latest_session_timestamp) / + (uint64_t)SECONDS_PER_DAY; + if (days > (uint64_t)SIZE_MAX) status = TRAINLOG_STATUS_DATABASE_ERROR; + else output->days_since_last_session = (size_t)days; + } + if (status == TRAINLOG_STATUS_OK) + status = load_top_feedback(database, start, now_utc, output); + if (status == TRAINLOG_STATUS_OK) + status = load_buckets(database, window, now_utc, output); + if (trainlog_database_read_snapshot_end(database, + status == TRAINLOG_STATUS_OK) != TRAINLOG_STATUS_OK) + status = TRAINLOG_STATUS_DATABASE_ERROR; + return status; +} + +TrainlogStatus trainlog_statistics_load_zones(TrainlogDatabase *database, + int64_t now_utc, TrainlogZoneStatistics *output, size_t capacity, + size_t *output_count, bool *output_partial) +{ + sqlite3_stmt *statement = NULL; + const char *sql = + "WITH actual_occurrences AS (SELECT se.id,se.session_row_id,se.exercise_row_id FROM session_exercises se " + "WHERE EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) " + "SELECT z.zone_id,COUNT(DISTINCT s.id)," + "COUNT(DISTINCT CASE WHEN z.role='primary' AND unixepoch(s.started_at)>=?1 THEN s.id END)," + "COUNT(DISTINCT CASE WHEN z.role='secondary' AND unixepoch(s.started_at)>=?1 THEN s.id END)," + "COUNT(DISTINCT CASE WHEN z.role='primary' AND unixepoch(s.started_at)>=?2 THEN s.id END)," + "COUNT(DISTINCT CASE WHEN z.role='secondary' AND unixepoch(s.started_at)>=?2 THEN s.id END)," + "COUNT(DISTINCT ao.exercise_row_id)," + "MAX(CASE WHEN z.role='primary' THEN s.started_at END)," + "MAX(CASE WHEN z.role='secondary' THEN s.started_at END) " + "FROM exercise_body_zones z JOIN actual_occurrences ao ON ao.exercise_row_id=z.exercise_row_id " + "JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at)<=?3 " + "GROUP BY z.zone_id ORDER BY z.zone_id;"; + TrainlogStatus status = TRAINLOG_STATUS_OK; + int step; + if (database == NULL || database->connection == NULL || output_count == NULL || + output_partial == NULL || (capacity > 0U && output == NULL)) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_count = 0U; *output_partial = false; + if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_int64(statement, 1, now_utc - INT64_C(7) * SECONDS_PER_DAY) != SQLITE_OK || + sqlite3_bind_int64(statement, 2, now_utc - INT64_C(30) * SECONDS_PER_DAY) != SQLITE_OK || + sqlite3_bind_int64(statement, 3, now_utc) != SQLITE_OK) goto fail; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + const unsigned char *zone = sqlite3_column_text(statement, 0); + size_t index = *output_count; + TrainlogZoneStatistics *value; + if (zone == NULL) goto fail; + if (index == capacity) { *output_partial = true; continue; } + value = &output[index]; + (void)memset(value, 0, sizeof(*value)); + if (!copy_text(statement, 0, value->zone_id, + sizeof(value->zone_id))) goto fail; + { + const unsigned char *latest_primary = sqlite3_column_text(statement, 7); + const unsigned char *latest_secondary = sqlite3_column_text(statement, 8); + if (!size_column(statement, 1, &value->sessions_any) || + !size_column(statement, 2, &value->primary_7) || + !size_column(statement, 3, &value->secondary_7) || + !size_column(statement, 4, &value->primary_30) || + !size_column(statement, 5, &value->secondary_30) || + !size_column(statement, 6, &value->distinct_exercises)) goto fail; + if (latest_primary != NULL && !copy_text(statement, 7, + value->latest_primary, sizeof(value->latest_primary))) goto fail; + if (latest_secondary != NULL && !copy_text(statement, 8, + value->latest_secondary, sizeof(value->latest_secondary))) goto fail; + } + ++(*output_count); + } + if (step != SQLITE_DONE) goto fail; + if (sqlite3_finalize(statement) != SQLITE_OK) status = TRAINLOG_STATUS_DATABASE_ERROR; + return status; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +static bool append_point(TrainlogExerciseStatistics *output, + TrainlogExerciseSeriesKind kind, int64_t timestamp, const char *label, + double value) +{ + size_t index = output->series_count[kind]; + TrainlogStatisticPoint *point; + if (!isfinite(value)) return false; + if (index == TRAINLOG_STATISTICS_SERIES_MAX) { + output->series_partial[kind] = true; + return true; + } + point = &output->series[kind][index]; + point->timestamp = timestamp; point->value = value; + if (snprintf(point->timestamp_label, sizeof(point->timestamp_label), "%s", + label) < 0) return false; + output->series_count[kind] = index + 1U; + return true; +} + +TrainlogStatus trainlog_statistics_load_exercise(TrainlogDatabase *database, + const char *exercise_id, int64_t now_utc, TrainlogExerciseStatistics *output) +{ + sqlite3_stmt *statement = NULL; + TrainlogStatus status = TRAINLOG_STATUS_OK; + const char *profile_sql = "SELECT name,tracking_mode,recording_mode,COALESCE(load_semantics,'') FROM exercises WHERE exercise_id=?1;"; + const char *series_sql = + "SELECT s.started_at,unixepoch(s.started_at),se.id," + "COUNT(p.id),COALESCE(SUM(p.reps),0),COALESCE(SUM(p.duration_seconds),0)+COALESCE(c.duration_seconds,0)," + "MAX(CASE WHEN p.weight_kg IS NOT NULL THEN p.weight_kg END)," + "COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND se.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0)," + "m.max_weight_kg,se.target_sets,se.target_reps,se.target_weight_kg," + "(SELECT COUNT(*) FROM exercise_feedback f WHERE f.session_exercise_row_id=se.id) " + "FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id " + "LEFT JOIN performed_sets p ON p.session_exercise_row_id=se.id LEFT JOIN continuous_activity c ON c.session_exercise_row_id=se.id " + "LEFT JOIN max_results m ON m.session_exercise_row_id=se.id WHERE e.exercise_id=?1 " + "AND (p.id IS NOT NULL OR c.id IS NOT NULL OR m.max_weight_kg IS NOT NULL) AND unixepoch(s.started_at)<=?2 " + "GROUP BY s.id,se.id ORDER BY unixepoch(s.started_at),s.session_id,se.entry_id;"; + int step; + if (database == NULL || database->connection == NULL || exercise_id == NULL || + exercise_id[0] == '\0' || output == NULL) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + (void)memset(output, 0, sizeof(*output)); + (void)snprintf(output->exercise_id, sizeof(output->exercise_id), "%s", exercise_id); + if (prepare(database->connection, profile_sql, &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) + goto fail; + if (sqlite3_step(statement) != SQLITE_ROW || + !copy_text(statement, 0, output->name, sizeof(output->name))) { + status = TRAINLOG_STATUS_NOT_FOUND; goto done; + } + { + const char *tracking = (const char *)sqlite3_column_text(statement, 1); + const char *recording = (const char *)sqlite3_column_text(statement, 2); + if (tracking == NULL || recording == NULL || + !copy_text(statement, 3, output->load_semantics, + sizeof(output->load_semantics))) goto fail; + output->tracking_mode = strcmp(tracking, "duration") == 0 + ? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS; + output->recording_mode = strcmp(recording, "continuous") == 0 + ? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS; + } + if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto fail; } + statement = NULL; + if (prepare(database->connection, series_sql, &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_int64(statement, 2, now_utc) != SQLITE_OK) goto fail; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + const unsigned char *at = sqlite3_column_text(statement, 0); + int64_t timestamp = sqlite3_column_int64(statement, 1); + size_t sets; + uint64_t reps, duration; + double volume = sqlite3_column_double(statement, 7); + if (at == NULL || !size_column(statement, 3, &sets) || + !u64_column(statement, 4, &reps) || !u64_column(statement, 5, &duration) || + !isfinite(volume)) goto fail; + ++output->occurrences; + ++output->sessions; /* corrected to distinct below after ordered scan */ + if (sets > SIZE_MAX - output->sets || reps > UINT64_MAX - output->repetitions || + duration > UINT64_MAX - output->duration_seconds) goto fail; + output->sets += sets; output->repetitions += reps; + output->duration_seconds += duration; output->loaded_volume_kg += volume; + if (output->first_at[0] == '\0') + (void)snprintf(output->first_at, sizeof(output->first_at), "%s", at); + (void)snprintf(output->last_at, sizeof(output->last_at), "%s", at); + if (sqlite3_column_type(statement, 6) != SQLITE_NULL) { + double load = sqlite3_column_double(statement, 6); + if (!isfinite(load)) goto fail; + if (!output->has_highest_load || load > output->highest_load_kg) { + output->highest_load_kg = load; output->has_highest_load = true; + } + if (!append_point(output, TRAINLOG_EXERCISE_SERIES_LOAD, + timestamp, (const char *)at, load)) goto fail; + } + if (sets > 0U && !append_point(output, TRAINLOG_EXERCISE_SERIES_SETS, + timestamp, (const char *)at, (double)sets)) goto fail; + if (reps > 0U && !append_point(output, TRAINLOG_EXERCISE_SERIES_REPETITIONS, + timestamp, (const char *)at, (double)reps)) goto fail; + if (volume > 0.0 && !append_point(output, TRAINLOG_EXERCISE_SERIES_VOLUME, + timestamp, (const char *)at, volume)) goto fail; + if (sqlite3_column_type(statement, 8) != SQLITE_NULL) { + double maximum = sqlite3_column_double(statement, 8); + if (!isfinite(maximum) || maximum <= 0.0) goto fail; + if (output->has_latest_max) { + output->previous_max_kg = output->latest_max_kg; + output->has_previous_max = true; + } + output->latest_max_kg = maximum; output->has_latest_max = true; + if (output->max_count == 0U || maximum > output->best_max_kg) + output->best_max_kg = maximum; + ++output->max_count; + (void)snprintf(output->latest_max_at, sizeof(output->latest_max_at), + "%s", at); + if (!append_point(output, TRAINLOG_EXERCISE_SERIES_MAX, + timestamp, (const char *)at, maximum)) goto fail; + } + if (sqlite3_column_type(statement, 9) != SQLITE_NULL) { + uint64_t target_sets = (uint64_t)sqlite3_column_int64(statement, 9); + if (target_sets > UINT64_MAX - output->planned_sets || + (uint64_t)sets > UINT64_MAX - output->actual_sets_for_plans) + goto fail; + output->planned_sets += target_sets; + output->actual_sets_for_plans += (uint64_t)sets; + if (sqlite3_column_type(statement, 10) != SQLITE_NULL) { + uint64_t target_reps = (uint64_t)sqlite3_column_int64(statement, 10); + if (target_reps != 0U && target_sets > UINT64_MAX / target_reps) + goto fail; + target_reps *= target_sets; + if (target_reps > UINT64_MAX - output->planned_repetitions || + reps > UINT64_MAX - output->actual_repetitions_for_plans) + goto fail; + output->planned_repetitions += target_reps; + output->actual_repetitions_for_plans += reps; + } + if (sqlite3_column_type(statement, 11) != SQLITE_NULL && + sqlite3_column_type(statement, 6) != SQLITE_NULL) { + double planned_weight = sqlite3_column_double(statement, 11); + double actual_weight = sqlite3_column_double(statement, 6); + if (!isfinite(planned_weight) || !isfinite(actual_weight)) goto fail; + output->latest_planned_weight_kg = planned_weight; + output->latest_actual_weight_kg = actual_weight; + output->has_latest_planned_actual_weight = true; + } + } + { + size_t feedback; + if (!size_column(statement, 12, &feedback) || + feedback > SIZE_MAX - output->immediate_feedback_count) goto fail; + output->immediate_feedback_count += feedback; + } + } + if (step != SQLITE_DONE) goto fail; + /* Occurrences of one exercise can repeat inside a session. This aggregate + * query is chronological but not session-ID preserving, so obtain the exact + * distinct count in one constant supplementary query. */ + if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto fail; } + statement = NULL; + if (prepare(database->connection, + "SELECT COUNT(DISTINCT CASE WHEN (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END)," + "COUNT(DISTINCT CASE WHEN unixepoch(s.started_at)>=?2-604800 AND (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END)," + "COUNT(DISTINCT CASE WHEN unixepoch(s.started_at)>=?2-2592000 AND (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END)," + "(SELECT p2.reps FROM performed_sets p2 JOIN session_exercises se2 ON se2.id=p2.session_exercise_row_id JOIN sessions s2 ON s2.id=se2.session_row_id JOIN exercises e2 ON e2.id=se2.exercise_row_id WHERE e2.exercise_id=?1 AND p2.reps IS NOT NULL AND p2.weight_kg IS NOT NULL AND unixepoch(s2.started_at)<=?2 ORDER BY p2.reps DESC,p2.weight_kg DESC,s2.started_at DESC LIMIT 1)," + "(SELECT p2.weight_kg FROM performed_sets p2 JOIN session_exercises se2 ON se2.id=p2.session_exercise_row_id JOIN sessions s2 ON s2.id=se2.session_row_id JOIN exercises e2 ON e2.id=se2.exercise_row_id WHERE e2.exercise_id=?1 AND p2.reps IS NOT NULL AND p2.weight_kg IS NOT NULL AND unixepoch(s2.started_at)<=?2 ORDER BY p2.reps DESC,p2.weight_kg DESC,s2.started_at DESC LIMIT 1) " + "FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE e.exercise_id=?1 AND unixepoch(s.started_at)<=?2;", + &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || + sqlite3_bind_int64(statement, 2, now_utc) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_ROW || + !size_column(statement, 0, &output->sessions) || + !size_column(statement, 1, &output->frequency_7) || + !size_column(statement, 2, &output->frequency_30)) goto fail; + if (sqlite3_column_type(statement, 3) != SQLITE_NULL) { + output->highest_repetitions = sqlite3_column_int(statement, 3); + output->has_repetitions_at_load = sqlite3_column_type(statement, 4) != SQLITE_NULL; + if (output->has_repetitions_at_load) + output->highest_repetitions_load_kg = sqlite3_column_double(statement, 4); + } +done: + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK) + status = TRAINLOG_STATUS_DATABASE_ERROR; + return status; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_statistics_list_maxima(TrainlogDatabase *database, + TrainlogMaxListItem *output, size_t capacity, size_t *output_count, + bool *output_partial) +{ + sqlite3_stmt *statement = NULL; + const char *sql = + "SELECT exercise_id,max_weight_kg,started_at FROM (" + "SELECT e.exercise_id,m.max_weight_kg,s.started_at," + "ROW_NUMBER() OVER (PARTITION BY e.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC) rank " + "FROM max_results m JOIN session_exercises se ON se.id=m.session_exercise_row_id " + "JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id) " + "WHERE rank=1 ORDER BY started_at DESC,exercise_id LIMIT ?1;"; + int step; + if (database == NULL || database->connection == NULL || output_count == NULL || + output_partial == NULL || (capacity > 0U && output == NULL) || + capacity == SIZE_MAX) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_count = 0U; *output_partial = false; + if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK) + goto fail; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogMaxListItem *item; + if (*output_count == capacity) { *output_partial = true; continue; } + item = &output[*output_count]; + (void)memset(item, 0, sizeof(*item)); + if (!copy_text(statement, 0, item->exercise_id, sizeof(item->exercise_id)) || + !isfinite(sqlite3_column_double(statement, 1)) || + !copy_text(statement, 2, item->latest_at, sizeof(item->latest_at))) goto fail; + item->latest_max_kg = sqlite3_column_double(statement, 1); + ++(*output_count); + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + return TRAINLOG_STATUS_OK; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} + +TrainlogStatus trainlog_statistics_list_exercises(TrainlogDatabase *database, + TrainlogExerciseListStatistic *output, size_t capacity, + size_t *output_count, bool *output_partial) +{ + sqlite3_stmt *statement = NULL; + char sql[2048]; + int written = snprintf(sql, sizeof(sql), + "%s SELECT e.exercise_id,COUNT(*),MAX(s.started_at) FROM actual_occurrences ao " + "JOIN sessions s ON s.id=ao.session_row_id JOIN exercises e ON e.id=ao.exercise_row_id " + "GROUP BY e.id ORDER BY MAX(unixepoch(s.started_at)) DESC,e.exercise_id LIMIT ?1;", + actual_occurrences_cte()); + int step; + if (database == NULL || database->connection == NULL || output_count == NULL || + output_partial == NULL || (capacity > 0U && output == NULL) || + capacity == SIZE_MAX || written < 0 || (size_t)written >= sizeof(sql)) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_count = 0U; *output_partial = false; + if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK || + sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK) + goto fail; + while ((step = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogExerciseListStatistic *item; + if (*output_count == capacity) { *output_partial = true; continue; } + item = &output[*output_count]; + (void)memset(item, 0, sizeof(*item)); + if (!copy_text(statement, 0, item->exercise_id, sizeof(item->exercise_id)) || + !size_column(statement, 1, &item->occurrences) || + !copy_text(statement, 2, item->latest_at, sizeof(item->latest_at))) goto fail; + ++(*output_count); + } + if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) + return TRAINLOG_STATUS_DATABASE_ERROR; + return TRAINLOG_STATUS_OK; +fail: + if (statement != NULL) (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; +} diff --git a/tui/src/tui.c b/tui/src/tui.c index ca697ca..2e50b6c 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -30,6 +30,7 @@ #include "trainlog/bodyviz.h" #include "trainlog/body_analytics.h" +#include "trainlog/chart.h" #include "trainlog/body_zone_catalog.h" #include "trainlog/catalog.h" #include "trainlog/duration.h" @@ -43,6 +44,7 @@ #include "trainlog/sync_screen_action.h" #include "trainlog/reps.h" #include "trainlog/session_generation.h" +#include "trainlog/statistics.h" #include "trainlog/session_generation_policy_internal.h" #include "trainlog/theme.h" #include "trainlog/timeutil.h" @@ -2674,6 +2676,24 @@ typedef struct TrainlogAppContext { TrainlogProfileController profile_controller; TrainlogSyncController sync_controller; TrainlogDashboardSnapshot dashboard; + TrainlogStatisticsSummary statistics; + TrainlogZoneStatistics statistics_zones[TRAINLOG_STATISTICS_ZONE_MAX]; + size_t statistics_zone_count; + TrainlogExerciseStatistics exercise_statistics; + TrainlogMaxListItem max_list_items[MAX_EXERCISES]; + size_t max_list_item_count; + bool max_list_partial; + bool max_show_all; + TrainlogExerciseListStatistic exercise_list_statistics[MAX_EXERCISES]; + size_t exercise_list_statistics_count; + bool exercise_list_statistics_partial; + TrainlogStatisticsWindow statistics_window; + int64_t statistics_now; + size_t statistics_graph; + size_t statistics_zone_selected; + TrainlogExerciseSeriesKind exercise_statistics_graph; + bool statistics_error; + bool statistics_zones_partial; TrainlogFocusTarget focus; TrainlogActionModel actions; TrainlogOverlayStack overlays; @@ -2710,7 +2730,6 @@ typedef struct TrainlogAppContext { TrainlogExercisePerformancePoint performance_points[MAX_SESSIONS]; size_t performance_count; TrainlogMeasuredMaxSummary max_summary; - size_t max_rounding_index; bool performance_error; TrainlogSessionSummary session_detail; TrainlogPersistedExerciseDetail session_entries[MAX_SESSION_EXERCISES]; @@ -2749,6 +2768,39 @@ static void app_shell_refresh_list(TrainlogAppContext *app); static void app_shell_load_body_global(TrainlogAppContext *app); static void app_shell_select_available_body_metric(TrainlogAppContext *app); +static void app_shell_load_statistics(TrainlogAppContext *app) +{ + time_t now = time(NULL); + app->statistics_now = now == (time_t)-1 ? 0 : (int64_t)now; + app->statistics_error = now == (time_t)-1 || + trainlog_statistics_load_summary(app->database, app->statistics_window, + (int64_t)now, &app->statistics) != TRAINLOG_STATUS_OK; +} + +static void app_shell_load_statistics_zones(TrainlogAppContext *app) +{ + time_t now = time(NULL); + app->statistics_zone_count = 0U; + app->statistics_zones_partial = false; + app->statistics_error = now == (time_t)-1 || + trainlog_statistics_load_zones(app->database, (int64_t)now, + app->statistics_zones, TRAINLOG_STATISTICS_ZONE_MAX, + &app->statistics_zone_count, &app->statistics_zones_partial) != + TRAINLOG_STATUS_OK; + if (app->statistics_zone_selected >= app->statistics_zone_count) + app->statistics_zone_selected = app->statistics_zone_count > 0U + ? app->statistics_zone_count - 1U : 0U; +} + +static void app_shell_load_exercise_statistics(TrainlogAppContext *app) +{ + time_t now = time(NULL); + app->statistics_error = now == (time_t)-1 || + trainlog_statistics_load_exercise(app->database, + app->exercise_detail.exercise_id, (int64_t)now, + &app->exercise_statistics) != TRAINLOG_STATUS_OK; +} + static void body_metric_slot(TrainlogBodyObservationInput *input, size_t field, bool **present, double **value) { @@ -3489,6 +3541,39 @@ static void app_shell_refresh_list(TrainlogAppContext *app) MAX_EXERCISES, &count) != TRAINLOG_STATUS_OK) app->list_error = true; capacity = MAX_EXERCISES; source_capped = count == capacity; + if (route == TRAINLOG_ROUTE_STATS_EXERCISE) { + app->exercise_list_statistics_count = 0U; + app->exercise_list_statistics_partial = false; + if (trainlog_statistics_list_exercises(app->database, + app->exercise_list_statistics, MAX_EXERCISES, + &app->exercise_list_statistics_count, + &app->exercise_list_statistics_partial) != TRAINLOG_STATUS_OK) + app->list_error = true; + source_capped = source_capped || app->exercise_list_statistics_partial; + } + if (route == TRAINLOG_ROUTE_MAX) { + size_t write = 0U; + app->max_list_item_count = 0U; + app->max_list_partial = false; + if (trainlog_statistics_list_maxima(app->database, + app->max_list_items, MAX_EXERCISES, + &app->max_list_item_count, &app->max_list_partial) != + TRAINLOG_STATUS_OK) app->list_error = true; + for (index = 0U; index < count; ++index) { + bool measured = false; + for (size_t maximum = 0U; maximum < app->max_list_item_count; + ++maximum) + if (strcmp(app->exercises[index].exercise_id, + app->max_list_items[maximum].exercise_id) == 0) { + measured = true; break; + } + if ((app->max_show_all && app->exercises[index].recording_mode == + TRAINLOG_RECORDING_SETS) || measured) + app->exercises[write++] = app->exercises[index]; + } + count = write; + source_capped = source_capped || app->max_list_partial; + } for (index = 0U; index < count; ++index) app->stable_ids[index] = app->exercises[index].exercise_id; } else if (route == TRAINLOG_ROUTE_EQUIPMENT) { @@ -3990,7 +4075,7 @@ static bool dashboard_load_zone_distribution(TrainlogAppContext *app, * 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. */ -static void app_shell_load_dashboard(TrainlogAppContext *app) +static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app) { TrainlogDashboardSnapshot *dashboard = &app->dashboard; TrainlogStatisticsPeriod period = dashboard->period; @@ -4416,6 +4501,8 @@ static void app_shell_open_exercise_analytics(TrainlogAppContext *app, app->performance_error = trainlog_measured_max_summarize( app->performance_points, app->performance_count, &app->max_summary) != TRAINLOG_STATUS_OK; + if (route == TRAINLOG_ROUTE_EXERCISE_MAX) + app_shell_load_exercise_statistics(app); (void)trainlog_navigation_open(&app->navigation, route, app->exercise_detail.exercise_id); app->content_scroll = 0U; @@ -5150,6 +5237,27 @@ static void app_shell_actions(TrainlogAppContext *app) if (route == TRAINLOG_ROUTE_HOME) app_shell_add_action(app, "session", '1', "1 Séance", 1U, TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL); + if (route == TRAINLOG_ROUTE_STATS) + app_shell_add_action(app, "statistics.open", TRAINLOG_KEY_ENTER, + "Entrée Ouvrir", 1U, TRAINLOG_INTENT_PRIMARY, route); + if (route == TRAINLOG_ROUTE_STATS_TRAINING) { + app_shell_add_action(app, "statistics.graph.previous", TRAINLOG_KEY_LEFT, + "← Graphe précédent", 1U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "statistics.graph.next", TRAINLOG_KEY_RIGHT, + "→ Graphe suivant", 2U, TRAINLOG_INTENT_NONE, route); + } + if (route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL) { + app_shell_add_action(app, "statistics.exercise.previous", TRAINLOG_KEY_LEFT, + "← Métrique précédente", 1U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "statistics.exercise.next", TRAINLOG_KEY_RIGHT, + "→ Métrique suivante", 2U, TRAINLOG_INTENT_NONE, route); + } + if (route == TRAINLOG_ROUTE_STATS_ZONES) { + app_shell_add_action(app, "statistics.zone.previous", TRAINLOG_KEY_UP, + "↑ Zone précédente", 1U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "statistics.zone.next", TRAINLOG_KEY_DOWN, + "↓ Zone suivante", 2U, TRAINLOG_INTENT_NONE, route); + } if (route == TRAINLOG_ROUTE_BODY || route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS) app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U, @@ -5305,12 +5413,12 @@ static void app_shell_actions(TrainlogAppContext *app) if (route == TRAINLOG_ROUTE_EXERCISES) app_shell_add_action(app, "exercise.create", 'a', "a Créer", 1U, TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_MAX) + app_shell_add_action(app, "max.filter", 'a', "a MAX mesurés/tous", 1U, + TRAINLOG_INTENT_NONE, route); if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) app_shell_add_action(app, "knowledge.close", 'k', "k Fermer", 1U, TRAINLOG_INTENT_BACK, TRAINLOG_ROUTE_EXERCISE_DETAIL); - if (route == TRAINLOG_ROUTE_EXERCISE_MAX) - app_shell_add_action(app, "max.rounding", 'r', "r Arrondi", 1U, - TRAINLOG_INTENT_NONE, route); if (route == TRAINLOG_ROUTE_SESSION_DETAIL) app_shell_add_action(app, "session.edit", 'e', "e Modifier", 1U, TRAINLOG_INTENT_NONE, route); @@ -5416,6 +5524,9 @@ static void app_shell_render_sidebar(TrainlogAppContext *app) case TRAINLOG_ROUTE_EXERCISE_MAX: subroute = "· MAX exercice"; break; case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: subroute = "· Fiche équipement"; break; case TRAINLOG_ROUTE_STATS_EXERCISE: subroute = "· Par exercice"; break; + case TRAINLOG_ROUTE_STATS_TRAINING: subroute = "· Vue globale"; break; + case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: subroute = "· Exercice"; break; + case TRAINLOG_ROUTE_STATS_ZONES: subroute = "· Zones"; break; case TRAINLOG_ROUTE_BODY: subroute = "· Mensurations"; break; case TRAINLOG_ROUTE_BODY_DETAIL: subroute = "· Relevé corporel"; break; case TRAINLOG_ROUTE_BODY_METRIC: subroute = "· Historique mesure"; break; @@ -5947,69 +6058,28 @@ static void app_shell_draw_performance_graph( static void app_shell_draw_body_points(TrainlogAppContext *app, const TrainlogBodyMetricPoint *points, size_t count, int top, int height, - const char *unit) + const char *unit, const char *title) { - size_t start; + TrainlogChartPoint chart_points[MAX_BODY_METRIC_POINTS]; + TrainlogChartRect rect; size_t index; - double minimum; - double maximum; - int left = 11; - int width = app->layout.content.width - left - 2; - if (count == 0U) { - trainlog_surface_printf(app->content, top, 2, - "Aucune donnée pour cette mesure."); - return; - } - if (count == 1U) { - trainlog_surface_printf(app->content, top, 2, - "%.2f %s · 1 relevé · tendance indisponible", points[0].value, unit); - return; - } - if (height < 3 || width < 10 || top + height >= app->layout.content.height) - return; - start = count > (size_t)width ? count - (size_t)width : 0U; - minimum = points[start].value; - maximum = minimum; - for (index = start + 1U; index < count; ++index) { - if (points[index].value < minimum) minimum = points[index].value; - if (points[index].value > maximum) maximum = points[index].value; - } - trainlog_surface_printf(app->content, top, 2, "haut %.1f", maximum); - trainlog_surface_printf(app->content, top + height - 1, 2, "bas %.1f", minimum); - trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, - TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); - for (int axis_row = top; axis_row < top + height; ++axis_row) - trainlog_surface_draw(app->content, axis_row, left - 2, 0x2502U); - { - int previous_x = -1; - int previous_y = -1; - size_t visible = count - start; - for (index = start; index < count; ++index) { - int x = left + (int)(((index - start) * (size_t)(width - 1)) / - (visible - 1U)); - int y = normalized_graph_row(points[index].value, minimum, maximum, - top, height); - if (previous_x >= 0) - app_shell_draw_chart_segment(app->content, previous_x, previous_y, - x, y); - trainlog_surface_draw(app->content, y, x, - index + 1U == count ? 0x25cfU : 0x25cbU); - previous_x = x; - previous_y = y; + if (count > MAX_BODY_METRIC_POINTS) count = MAX_BODY_METRIC_POINTS; + for (index = 0U; index < count; ++index) { + TrainlogTimestampKey timestamp; + if (!trainlog_timestamp_parse(points[index].observed_at, + strlen(points[index].observed_at), ×tamp)) { + count = index; + break; } + chart_points[index].timestamp = timestamp.utc_second; + chart_points[index].value = points[index].value; + chart_points[index].timestamp_label = points[index].observed_at; } - trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, - TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); - { - char oldest[11]; - char newest[11]; - exercise_short_date(points[start].observed_at, oldest); - exercise_short_date(points[count - 1U].observed_at, newest); - trainlog_surface_printf(app->content, top + height, left, - "%s → %s · %zu relevés · %s", oldest, newest, count, unit); - } - trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, - TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + rect.top = top; + rect.left = 2; + rect.height = height; + rect.width = app->layout.content.width - 4; + trainlog_chart_render(app->content, rect, chart_points, count, unit, title); } static void app_shell_render_body_profile(TrainlogAppContext *app) @@ -6078,9 +6148,13 @@ static void app_shell_render_body_profile(TrainlogAppContext *app) trainlog_surface_printf(app->content, 5, 2, "PROFIL CIRCONFÉRENCES · échelle au plus grand cm présent"); - evolution_top = app->layout.content.height >= 26 - ? 20 : app->layout.content.height - 6; - if (evolution_top < 10) evolution_top = 10; + /* WHY: sparse observations should donate their unused profile rows to the + * chart. The current content-plane height is the only geometry source, so + * resize redraws cannot retain stale coordinates. */ + evolution_top = profile_row + (int)circumference_count + 1; + if (evolution_top < 8) evolution_top = 8; + if (evolution_top > app->layout.content.height - 7) + evolution_top = app->layout.content.height - 7; if (bar_capacity < 1) bar_capacity = 1; for (index = 0U; index < 13U && profile_row < evolution_top - 1; ++index) { int length; @@ -6118,13 +6192,15 @@ static void app_shell_render_body_profile(TrainlogAppContext *app) "ÉVOLUTION DANS LE TEMPS · aucune métrique disponible"); return; } - trainlog_surface_printf(app->content, evolution_top, 2, - "ÉVOLUTION DANS LE TEMPS · ←/→ %s", - BODY_METRICS[app->body_metric_selected].label); - app_shell_draw_body_points(app, series->points, series->count, - evolution_top + 1, - app->layout.content.height - evolution_top - 2, - BODY_METRICS[app->body_metric_selected].unit); + { + char title[128]; + (void)snprintf(title, sizeof(title), + "ÉVOLUTION DANS LE TEMPS · ←/→ %s", + BODY_METRICS[app->body_metric_selected].label); + app_shell_draw_body_points(app, series->points, series->count, + evolution_top, app->layout.content.height - evolution_top - 1, + BODY_METRICS[app->body_metric_selected].unit, title); + } } static void app_shell_render_body_controller(TrainlogAppContext *app) @@ -6472,7 +6548,7 @@ static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); } -static void app_shell_render_dashboard(TrainlogAppContext *app) +static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContext *app) { const TrainlogDashboardSnapshot *dashboard = &app->dashboard; double body_values[MAX_BODY_METRIC_POINTS]; @@ -6943,6 +7019,380 @@ static void app_shell_render_sync(TrainlogAppContext *app) } } +static const char *statistics_window_label(TrainlogStatisticsWindow window) +{ + switch (window) { + case TRAINLOG_STATISTICS_7_DAYS: return "semaine courante"; + case TRAINLOG_STATISTICS_30_DAYS: return "mois courant"; + case TRAINLOG_STATISTICS_ALL: return "Tout l’historique"; + } + return "Période"; +} + +static void statistics_duration(char *output, size_t capacity, uint64_t seconds) +{ + uint64_t hours = seconds / UINT64_C(3600); + uint64_t minutes = (seconds % UINT64_C(3600)) / UINT64_C(60); + (void)snprintf(output, capacity, "%lluh%02llu", + (unsigned long long)hours, (unsigned long long)minutes); +} + +static void app_shell_render_statistics_hub(TrainlogAppContext *app) +{ + static const char *const labels[] = { + "Corps · poids et mensurations", + "Entraînement · vue globale et régularité", + "Exercices · faits et courbes par identité", + "Zones · travail principal et secondaire", + "MAX · historique des tests enregistrés" + }; + size_t index; + trainlog_surface_printf(app->content, 3, 2, + "Centre d’analyse · données enregistrées uniquement"); + for (index = 0U; index < 5U; ++index) { + bool selected = app->content_selected == index; + trainlog_surface_set_role(app->content, + selected ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, + selected ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 5 + (int)index * 2, 2, + "%s %s", selected ? "▶" : " ", labels[index]); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (app->layout.content.height > 15) + trainlog_surface_printf(app->content, 15, 2, + "Entrée ouvrir · aucune recommandation ni score global"); +} + +static void app_shell_render_statistics_chart(TrainlogAppContext *app, + int top, int height) +{ + TrainlogChartPoint points[TRAINLOG_STATISTICS_BUCKET_MAX]; + size_t count = app->statistics.bucket_count; + size_t index; + const char *unit; + const char *metric; + char title[128]; + TrainlogChartOptions options = {0}; + if (count > TRAINLOG_STATISTICS_BUCKET_MAX) + count = TRAINLOG_STATISTICS_BUCKET_MAX; + for (index = 0U; index < count; ++index) { + const TrainlogStatisticsBucket *bucket = &app->statistics.buckets[index]; + points[index].timestamp = bucket->timestamp; + points[index].timestamp_label = bucket->label; + if (app->statistics_graph == 0U) + points[index].value = bucket->loaded_volume_kg; + else if (app->statistics_graph == 1U) + points[index].value = (double)bucket->sessions; + else if (app->statistics_graph == 2U) + points[index].value = (double)bucket->sets; + else if (app->statistics_graph == 3U) + points[index].value = (double)bucket->repetitions; + else points[index].value = (double)bucket->immediate_feedback_count; + } + if (app->statistics_graph == 0U) { metric = "Volume chargé"; unit = "kg"; } + else if (app->statistics_graph == 1U) { metric = "Séances"; unit = "séances"; } + else if (app->statistics_graph == 2U) { metric = "Séries"; unit = "séries"; } + else if (app->statistics_graph == 3U) { metric = "Répétitions"; unit = "reps"; } + else { metric = "Ressentis immédiats"; unit = "retours"; } + (void)snprintf(title, sizeof(title), "←/→ %s · %s · %zu/5", metric, + app->statistics_window == TRAINLOG_STATISTICS_7_DAYS + ? "semaines calendaires" + : app->statistics_window == TRAINLOG_STATISTICS_30_DAYS + ? "mois calendaires" + : app->statistics.bucket_kind == + TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH + ? "mois · historique complet" + : "semaines · historique complet", + app->statistics_graph + 1U); + options.non_negative = true; + options.zero_baseline = true; + if (count > 0U) { + options.has_time_domain = true; + options.minimum_timestamp = points[0].timestamp; + options.maximum_timestamp = + app->statistics.buckets[count - 1U].end_timestamp; + } + trainlog_chart_render_bars(app->content, + (TrainlogChartRect){top, 2, height, app->layout.content.width - 4}, + points, count, unit, title, &options); +} + +static void app_shell_render_statistics_training(TrainlogAppContext *app) +{ + const TrainlogStatisticsSummary *value = &app->statistics; + char activity[32]; + char session_duration[32]; + int chart_top = 10; + if (app->statistics_error) { + trainlog_surface_printf(app->content, 4, 2, + "Impossible de calculer les statistiques enregistrées."); + return; + } + statistics_duration(activity, sizeof(activity), value->activity_duration_seconds); + statistics_duration(session_duration, sizeof(session_duration), + value->session_duration_seconds); + if (app->layout.content.width >= 86 && app->layout.content.height >= 24) { + int right = app->layout.content.width / 2 + 1; + trainlog_surface_printf(app->content, 3, 2, + "Période : %s · 1 7j · 2 30j · 3 Tout", + statistics_window_label(app->statistics_window)); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_ACCENT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + trainlog_surface_printf(app->content, 5, 2, "ACTIVITÉ"); + trainlog_surface_printf(app->content, 5, right, "TRAVAIL"); + trainlog_surface_printf(app->content, 9, 2, "RESSENTIS"); + trainlog_surface_printf(app->content, 9, right, "PRÉVU / RÉALISÉ"); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 6, 2, + "Séances %zu · jours %zu · semaines %zu", value->sessions, + value->active_days, value->active_weeks); + if (value->sessions > 0U) + trainlog_surface_printf(app->content, 7, 2, + "Exercices %zu · occurrences %zu · dernière J-%zu", + value->distinct_exercises, value->occurrences, + value->days_since_last_session); + else trainlog_surface_printf(app->content, 7, 2, + "Exercices 0 · occurrences 0 · aucune séance"); + trainlog_surface_printf(app->content, 6, right, + "Séries %zu · répétitions %llu", value->sets, + (unsigned long long)value->repetitions); + trainlog_surface_printf(app->content, 7, right, + "Volume %.1f kg · activité %s", value->loaded_volume_kg, activity); + if (value->sessions_with_duration > 0U) + trainlog_surface_printf(app->content, 8, right, + "Séances horodatées %s · moyenne %.0f min", + session_duration, + (double)value->session_duration_seconds / 60.0 / + (double)value->sessions_with_duration); + else trainlog_surface_printf(app->content, 8, right, + "Durée moyenne indisponible"); + trainlog_surface_printf(app->content, 10, 2, + "Immédiats %zu · J+1 séance %zu", value->immediate_feedback_count, + value->followup_count); + if (value->top_feedback_count > 0U) + trainlog_surface_printf(app->content, 11, 2, "%s · %zu retour%s", + value->top_feedback_exercise, value->top_feedback_count, + value->top_feedback_count == 1U ? "" : "s"); + else trainlog_surface_printf(app->content, 11, 2, + "Texte libre non analysé"); + trainlog_surface_printf(app->content, 10, right, + "Occurrences liées %zu · séries %llu/%llu", + value->planned_actual_occurrences, + (unsigned long long)value->planned_sets, + (unsigned long long)value->actual_sets_for_plans); + trainlog_surface_printf(app->content, 11, right, + "Répétitions %llu/%llu", + (unsigned long long)value->planned_repetitions, + (unsigned long long)value->actual_repetitions_for_plans); + app_shell_render_statistics_chart(app, 13, + app->layout.content.height - 14); + return; + } + trainlog_surface_printf(app->content, 3, 2, + "Période : %s · 1 7j · 2 30j · 3 Tout", + statistics_window_label(app->statistics_window)); + trainlog_surface_printf(app->content, 4, 2, + "Séances %zu · exercices %zu · occurrences %zu · jours %zu · semaines %zu", + value->sessions, value->distinct_exercises, value->occurrences, + value->active_days, value->active_weeks); + if (value->sessions > 0U && app->layout.content.width > 82) + trainlog_surface_printf(app->content, 4, 73, + "Dernière : J-%zu", value->days_since_last_session); + trainlog_surface_printf(app->content, 5, 2, + "Séries %zu · répétitions %llu · durée d’activité %s", + value->sets, (unsigned long long)value->repetitions, activity); + if (value->sessions_with_duration > 0U) + trainlog_surface_printf(app->content, 6, 2, + "Durée séances horodatées %s · moyenne %.0f min (%zu séance%s)", + session_duration, + (double)value->session_duration_seconds / 60.0 / + (double)value->sessions_with_duration, + value->sessions_with_duration, + value->sessions_with_duration == 1U ? "" : "s"); + else trainlog_surface_printf(app->content, 6, 2, + "Durée moyenne : indisponible (fin de séance absente)"); + trainlog_surface_printf(app->content, 7, 2, + "Volume chargé valide : %.1f kg", value->loaded_volume_kg); + trainlog_surface_printf(app->content, 8, 2, + "Prévu/réalisé mêmes occurrences : %zu · séries %llu/%llu · reps %llu/%llu", + value->planned_actual_occurrences, + (unsigned long long)value->planned_sets, + (unsigned long long)value->actual_sets_for_plans, + (unsigned long long)value->planned_repetitions, + (unsigned long long)value->actual_repetitions_for_plans); + if (value->top_feedback_count > 0U && app->layout.content.width > 85) + trainlog_surface_printf(app->content, 9, 2, + "Ressentis immédiats %zu · plus fréquent %s (%zu) · J+1 séance %zu · texte libre non analysé", + value->immediate_feedback_count, value->top_feedback_exercise, + value->top_feedback_count, value->followup_count); + else trainlog_surface_printf(app->content, 9, 2, + "Ressentis : immédiats %zu · J+1 séance %zu · texte libre non analysé", + value->immediate_feedback_count, value->followup_count); + if (app->layout.content.height - chart_top >= 3) + app_shell_render_statistics_chart(app, chart_top, + app->layout.content.height - chart_top - 1); +} + +static const char *exercise_series_label(TrainlogExerciseSeriesKind kind) +{ + switch (kind) { + case TRAINLOG_EXERCISE_SERIES_LOAD: return "Charge observée"; + case TRAINLOG_EXERCISE_SERIES_VOLUME: return "Volume chargé"; + case TRAINLOG_EXERCISE_SERIES_REPETITIONS: return "Répétitions"; + case TRAINLOG_EXERCISE_SERIES_SETS: return "Séries"; + case TRAINLOG_EXERCISE_SERIES_MAX: return "MAX enregistrés"; + case TRAINLOG_EXERCISE_SERIES_COUNT: break; + } + return "Mesure"; +} + +static const char *exercise_series_unit(TrainlogExerciseSeriesKind kind) +{ + if (kind == TRAINLOG_EXERCISE_SERIES_LOAD || + kind == TRAINLOG_EXERCISE_SERIES_VOLUME || + kind == TRAINLOG_EXERCISE_SERIES_MAX) return "kg"; + return kind == TRAINLOG_EXERCISE_SERIES_REPETITIONS ? "reps" : "séries"; +} + +static void app_shell_render_exercise_statistics(TrainlogAppContext *app) +{ + const TrainlogExerciseStatistics *value = &app->exercise_statistics; + TrainlogChartPoint points[TRAINLOG_STATISTICS_SERIES_MAX]; + TrainlogExerciseSeriesKind kind = app->exercise_statistics_graph; + size_t count = value->series_count[kind]; + char title[128]; + size_t index; + TrainlogChartOptions options = {0}; + if (app->statistics_error) { + trainlog_surface_printf(app->content, 4, 2, + "Impossible de calculer les statistiques de cet exercice."); + return; + } + trainlog_surface_printf(app->content, 3, 2, "%s · %s", + value->name, value->exercise_id); + trainlog_surface_printf(app->content, 4, 2, + "Occurrences %zu · séances %zu · séries %zu · répétitions %llu", + value->occurrences, value->sessions, value->sets, + (unsigned long long)value->repetitions); + trainlog_surface_printf(app->content, 5, 2, + "Première %s · dernière %s · fréquence 7j %zu · 30j %zu", + value->first_at[0] != '\0' ? value->first_at : "—", + value->last_at[0] != '\0' ? value->last_at : "—", + value->frequency_7, value->frequency_30); + if (strcmp(value->load_semantics, "external") == 0) + trainlog_surface_printf(app->content, 6, 2, + "Charge max %.1f kg · volume total %.1f kg · moyen/occ. %.1f kg · /séance %.1f kg", + value->has_highest_load ? value->highest_load_kg : 0.0, + value->loaded_volume_kg, + value->occurrences > 0U ? value->loaded_volume_kg / + (double)value->occurrences : 0.0, + value->sessions > 0U ? value->loaded_volume_kg / + (double)value->sessions : 0.0); + else if (value->duration_seconds > 0U) + trainlog_surface_printf(app->content, 6, 2, + "Durée enregistrée : %llu s · volume chargé non applicable", + (unsigned long long)value->duration_seconds); + else trainlog_surface_printf(app->content, 6, 2, + "Volume chargé non applicable à ce profil."); + trainlog_surface_printf(app->content, 7, 2, + "MAX %zu · ressentis immédiats %zu · prévu/réalisé séries %llu/%llu", + value->max_count, value->immediate_feedback_count, + (unsigned long long)value->planned_sets, + (unsigned long long)value->actual_sets_for_plans); + if (value->has_latest_planned_actual_weight && app->layout.content.width > 86) + trainlog_surface_printf(app->content, 7, 66, "charge %.1f/%.1f kg", + value->latest_planned_weight_kg, value->latest_actual_weight_kg); + for (index = 0U; index < count; ++index) { + points[index].timestamp = value->series[kind][index].timestamp; + points[index].value = value->series[kind][index].value; + points[index].timestamp_label = value->series[kind][index].timestamp_label; + } + (void)snprintf(title, sizeof(title), "←/→ %s · %d/5", + exercise_series_label(kind), (int)kind + 1); + options.non_negative = true; + trainlog_chart_render_line(app->content, + (TrainlogChartRect){9, 2, app->layout.content.height - 10, + app->layout.content.width - 4}, + points, count, exercise_series_unit(kind), title, &options); +} + +static void app_shell_render_statistics_zones(TrainlogAppContext *app) +{ + size_t index; + size_t maximum = 1U; + int row = 9; + if (app->statistics_error) { + trainlog_surface_printf(app->content, 4, 2, + "Impossible de calculer les statistiques par zone."); + return; + } + if (app->statistics_zone_count == 0U) { + trainlog_surface_printf(app->content, 4, 2, + "Aucune zone associée à un entraînement enregistré."); + return; + } + for (index = 0U; index < app->statistics_zone_count; ++index) { + TrainlogZoneStatistics *zone = &app->statistics_zones[index]; + const TrainlogBodyZone *catalog = trainlog_body_zone_catalog_lookup(zone->zone_id); + if (catalog != NULL) + (void)snprintf(zone->label, sizeof(zone->label), "%s", catalog->display_name); + if (zone->primary_30 > maximum) maximum = zone->primary_30; + if (zone->secondary_30 > maximum) maximum = zone->secondary_30; + } + { + const TrainlogZoneStatistics *selected = + &app->statistics_zones[app->statistics_zone_selected]; + trainlog_surface_printf(app->content, 3, 2, + "%s · séances %zu · exercices distincts %zu", + selected->label, selected->sessions_any, + selected->distinct_exercises); + trainlog_surface_printf(app->content, 4, 2, + "7j principal %zu · secondaire %zu · 30j principal %zu · secondaire %zu", + selected->primary_7, selected->secondary_7, + selected->primary_30, selected->secondary_30); + trainlog_surface_printf(app->content, 5, 2, + "Dernier principal : %s", + selected->latest_primary[0] != '\0' ? selected->latest_primary : "—"); + trainlog_surface_printf(app->content, 6, 2, + "Dernier secondaire : %s", + selected->latest_secondary[0] != '\0' ? selected->latest_secondary : "—"); + trainlog_surface_printf(app->content, 7, 2, + "↑/↓ zone · barres = séances distinctes sur 30 jours"); + } + for (index = 0U; index < app->statistics_zone_count && + row + 1 < app->layout.content.height; ++index) { + const TrainlogZoneStatistics *zone = &app->statistics_zones[index]; + int capacity = app->layout.content.width - 34; + int primary; + int secondary; + if (capacity < 4) capacity = 4; + primary = (int)((zone->primary_30 * (size_t)capacity) / maximum); + secondary = (int)((zone->secondary_30 * (size_t)capacity) / maximum); + trainlog_surface_printf(app->content, row, 2, "%c %-15.15s P", + index == app->statistics_zone_selected ? '>' : ' ', zone->label); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (int column = 0; column < primary; ++column) + trainlog_surface_draw(app->content, row, 22 + column, 0x2588U); + trainlog_surface_printf(app->content, row, 24 + capacity, "%zu", + zone->primary_30); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, row + 1, 19, "S"); + for (int column = 0; column < secondary; ++column) + trainlog_surface_draw(app->content, row + 1, 22 + column, 0x2584U); + trainlog_surface_printf(app->content, row + 1, 24 + capacity, "%zu", + zone->secondary_30); + row += 2; + } + if (app->statistics_zones_partial) + trainlog_surface_printf(app->content, app->layout.content.height - 1, 2, + "Vue partielle : capacité locale de présentation atteinte."); +} + static void app_shell_render_content(TrainlogAppContext *app) { TrainlogAppRoute route = app->navigation.current.route; @@ -6989,7 +7439,13 @@ static void app_shell_render_content(TrainlogAppContext *app) trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); } else if (route == TRAINLOG_ROUTE_STATS) { - app_shell_render_dashboard(app); + app_shell_render_statistics_hub(app); + } else if (route == TRAINLOG_ROUTE_STATS_TRAINING) { + app_shell_render_statistics_training(app); + } else if (route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL) { + app_shell_render_exercise_statistics(app); + } else if (route == TRAINLOG_ROUTE_STATS_ZONES) { + app_shell_render_statistics_zones(app); } else if (route == TRAINLOG_ROUTE_SESSION_DETAIL) { int row = 3; if (app->session_detail_error) trainlog_surface_printf(app->content, row, 2, @@ -7298,50 +7754,52 @@ static void app_shell_render_content(TrainlogAppContext *app) } } } else if (route == TRAINLOG_ROUTE_EXERCISE_MAX) { - static const double increments[] = {0.5, 1.0, 2.5, 5.0}; - const TrainlogMeasuredMaxSummary *summary = &app->max_summary; + const TrainlogExerciseStatistics *stats = &app->exercise_statistics; + TrainlogChartPoint points[TRAINLOG_STATISTICS_SERIES_MAX]; + size_t count = stats->series_count[TRAINLOG_EXERCISE_SERIES_MAX]; + size_t index; int row = 4; trainlog_surface_printf(app->content, row++, 2, "%s", app->exercise_detail.name); - if (app->performance_error) trainlog_surface_printf(app->content, row, 2, - "Impossible de lire les tests de MAX."); - else if (!summary->found) { - trainlog_surface_printf(app->content, row++, 2, - "Aucun MAX mesuré réussi."); + if (app->performance_error || app->statistics_error) trainlog_surface_printf(app->content, row, 2, - "Seules les séances explicitement « Test de max » comptent."); + "Impossible de lire les tests de MAX."); + else if (!stats->has_latest_max) { + trainlog_surface_printf(app->content, row, 2, "Aucun MAX enregistré."); } else { - char current[128]; char record[128]; - exercise_format_performance(&summary->current, current, sizeof(current)); - exercise_format_performance(&summary->record, record, sizeof(record)); - trainlog_surface_printf(app->content, row++, 2, "Tests MAX : %zu · réussis : %zu", - summary->test_count, summary->successful_test_count); - trainlog_surface_printf(app->content, row++, 2, "Actuel : %s", current); - trainlog_surface_printf(app->content, row++, 2, "Record même mode : %s", record); - if (summary->current.load_mode == TRAINLOG_LOAD_EXTERNAL) { - static const double percentages[] = {60.0, 70.0, 80.0, 90.0}; - double working[4]; size_t index; bool valid = true; - for (index = 0U; index < 4U; ++index) - if (trainlog_measured_max_working_load(&summary->current, - percentages[index], increments[app->max_rounding_index], - &working[index]) != TRAINLOG_STATUS_OK) valid = false; - if (valid) trainlog_surface_printf(app->content, row++, 2, - "Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg", - working[0], working[1], working[2], working[3]); + trainlog_surface_printf(app->content, row++, 2, + "Tests enregistrés : %zu · dernier %.1f kg · meilleur %.1f kg", + stats->max_count, stats->latest_max_kg, stats->best_max_kg); + trainlog_surface_printf(app->content, row++, 2, "Date du dernier : %s", + stats->latest_max_at); + if (stats->has_previous_max) { + double difference = stats->latest_max_kg - stats->previous_max_kg; trainlog_surface_printf(app->content, row++, 2, - "Arrondi %.1f kg · aucun 1RM estimé", - increments[app->max_rounding_index]); - } else trainlog_surface_printf(app->content, row++, 2, - summary->current.load_mode == TRAINLOG_LOAD_ASSISTANCE - ? "Assistance : moins de kg = mieux · pourcentages non applicables" - : "Sans charge externe · pourcentages non applicables"); - { - int graph_top = row + 1; - int graph_height = app_shell_graph_height(app, graph_top); - app_shell_draw_performance_graph(app, &summary->current, - summary->current.load_mode, - app->exercise_detail.tracking_mode, graph_top, - graph_height, true); + "Précédent %.1f kg · différence %+.1f kg%s", + stats->previous_max_kg, difference, + stats->previous_max_kg != 0.0 ? " · pourcentage calculable" : ""); + if (stats->previous_max_kg != 0.0) + trainlog_surface_printf(app->content, row++, 2, + "Différence relative : %+.1f%%", + difference * 100.0 / stats->previous_max_kg); } + for (index = 0U; index < count; ++index) { + points[index].timestamp = stats->series[ + TRAINLOG_EXERCISE_SERIES_MAX][index].timestamp; + points[index].value = stats->series[ + TRAINLOG_EXERCISE_SERIES_MAX][index].value; + points[index].timestamp_label = stats->series[ + TRAINLOG_EXERCISE_SERIES_MAX][index].timestamp_label; + } + if (app->layout.content.height - row >= 3) + { + TrainlogChartOptions options = {0}; + options.non_negative = true; + trainlog_chart_render_line(app->content, + (TrainlogChartRect){row, 2, + app->layout.content.height - row - 1, + app->layout.content.width - 4}, + points, count, "kg", "Historique des MAX", &options); + } } } else if (route == TRAINLOG_ROUTE_BODY) { app_shell_render_body_profile(app); @@ -7356,7 +7814,8 @@ static void app_shell_render_content(TrainlogAppContext *app) "Impossible de lire cet historique."); else app_shell_draw_body_points(app, app->body_metric_points, app->body_metric_count, 6, graph_height, - BODY_METRICS[app->body_metric_selected].unit); + BODY_METRICS[app->body_metric_selected].unit, + "ÉVOLUTION DANS LE TEMPS"); if (app->body_metric_partial) trainlog_surface_printf(app->content, app->layout.content.height - 1, 2, @@ -7372,6 +7831,11 @@ static void app_shell_render_content(TrainlogAppContext *app) } else if (app_shell_is_list_route(route)) { size_t index; int row = 5; + if (route == TRAINLOG_ROUTE_MAX) + trainlog_surface_printf(app->content, 2, 2, + "Vue : %s · a afficher %s", + app->max_show_all ? "tous les exercices compatibles" : "MAX enregistrés", + app->max_show_all ? "seulement les MAX" : "tous les compatibles"); trainlog_surface_printf(app->content, 3, 2, "Recherche : %s%s", app->search.bytes > 0U ? app->search.text : "—", app->search.focused ? " [saisie]" : ""); @@ -7409,7 +7873,37 @@ static void app_shell_render_content(TrainlogAppContext *app) (void)snprintf(secondary, sizeof(secondary), "%s", date); } else { name = app->exercises[index].name; - (void)snprintf(secondary, sizeof(secondary), "%s", + if (route == TRAINLOG_ROUTE_MAX) { + bool measured = false; + for (size_t maximum = 0U; maximum < app->max_list_item_count; + ++maximum) + if (strcmp(app->exercises[index].exercise_id, + app->max_list_items[maximum].exercise_id) == 0) { + char date[9]; + body_short_date(app->max_list_items[maximum].latest_at, date); + (void)snprintf(secondary, sizeof(secondary), + "%.1f kg %s", + app->max_list_items[maximum].latest_max_kg, date); + measured = true; break; + } + if (!measured) (void)snprintf(secondary, + sizeof(secondary), "—"); + } else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) { + bool found = false; + for (size_t fact = 0U; + fact < app->exercise_list_statistics_count; ++fact) + if (strcmp(app->exercises[index].exercise_id, + app->exercise_list_statistics[fact].exercise_id) == 0) { + char date[9]; + body_short_date(app->exercise_list_statistics[fact].latest_at, + date); + (void)snprintf(secondary, sizeof(secondary), "%zu fois · %s", + app->exercise_list_statistics[fact].occurrences, date); + found = true; break; + } + if (!found) (void)snprintf(secondary, sizeof(secondary), + "aucune donnée"); + } else (void)snprintf(secondary, sizeof(secondary), "%s", app->exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS ? "reps" : "durée"); } @@ -7698,8 +8192,10 @@ static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route } else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR && session->generated_preview && session->phase != TRAINLOG_SESSION_GENERATOR_WARNING) session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; - if (route == TRAINLOG_ROUTE_STATS) - app_shell_load_dashboard(app); + if (route == TRAINLOG_ROUTE_STATS_TRAINING) + app_shell_load_statistics(app); + else if (route == TRAINLOG_ROUTE_STATS_ZONES) + app_shell_load_statistics_zones(app); else if (route == TRAINLOG_ROUTE_BODY_METRIC) app_shell_load_body_metric(app); else if (route == TRAINLOG_ROUTE_BODY || @@ -7736,8 +8232,10 @@ static bool app_shell_back(TrainlogAppContext *app) app->search = app->saved_searches[app->navigation.current.route]; app_shell_refresh_list(app); } - if (app->navigation.current.route == TRAINLOG_ROUTE_STATS) - app_shell_load_dashboard(app); + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING) + app_shell_load_statistics(app); + else if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_ZONES) + app_shell_load_statistics_zones(app); else if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) { app_shell_load_body_global(app); app_shell_select_available_body_metric(app); @@ -7788,10 +8286,10 @@ static void app_shell_primary(TrainlogAppContext *app) TRAINLOG_ROUTE_SESSION_MANUAL, TRAINLOG_ROUTE_SESSIONS_COMPLETED}; app_shell_open_route(app, targets[app->content_selected % 3U]); } else if (route == TRAINLOG_ROUTE_STATS) { - static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE, - TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_SESSIONS_COMPLETED, - TRAINLOG_ROUTE_EXERCISES}; - app_shell_open_route(app, targets[app->content_selected % 4U]); + static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_BODY, + TRAINLOG_ROUTE_STATS_TRAINING, TRAINLOG_ROUTE_STATS_EXERCISE, + TRAINLOG_ROUTE_STATS_ZONES, TRAINLOG_ROUTE_MAX}; + app_shell_open_route(app, targets[app->content_selected % 5U]); } else if (route == TRAINLOG_ROUTE_SESSION_MANUAL || route == TRAINLOG_ROUTE_SESSION_CURRENT || route == TRAINLOG_ROUTE_SESSION_GENERATOR) { @@ -7821,8 +8319,10 @@ static void app_shell_primary(TrainlogAppContext *app) } else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) { app->exercise_detail = app->exercises[app->list.selected_index]; - app_shell_open_exercise_analytics(app, - TRAINLOG_ROUTE_EXERCISE_PERFORMANCE); + app_shell_load_exercise_statistics(app); + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL, + app->exercise_detail.exercise_id); } else if (route == TRAINLOG_ROUTE_MAX) { app->exercise_detail = app->exercises[app->list.selected_index]; app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX); @@ -8985,16 +9485,16 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) app->focus = TRAINLOG_FOCUS_SEARCH; return; } - if (app->navigation.current.route == TRAINLOG_ROUTE_STATS && - key >= '1' && key <= '5') { - static const TrainlogStatisticsPeriod periods[] = { - TRAINLOG_STATS_7_DAYS, TRAINLOG_STATS_30_DAYS, - TRAINLOG_STATS_90_DAYS, TRAINLOG_STATS_YEAR, TRAINLOG_STATS_ALL + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING && + key >= '1' && key <= '3') { + static const TrainlogStatisticsWindow periods[] = { + TRAINLOG_STATISTICS_7_DAYS, TRAINLOG_STATISTICS_30_DAYS, + TRAINLOG_STATISTICS_ALL }; - /* Compact keyboard contract: changing period reloads only the - * controller-owned read snapshot; rendering remains SQL-free. */ - app->dashboard.period = periods[(size_t)(key - '1')]; - app_shell_load_dashboard(app); + /* CONTRACT: a period switch refreshes one controller-owned snapshot; + * rendering stays SQL-free and all graph coordinates are recomputed. */ + app->statistics_window = periods[(size_t)(key - '1')]; + app_shell_load_statistics(app); return; } if (app_shell_dispatch_exercise_controller(app, key)) return; @@ -9006,12 +9506,52 @@ static void app_shell_dispatch(TrainlogAppContext *app, int key) if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS || app->navigation.current.route == TRAINLOG_ROUTE_STATS) && (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { - size_t count = app->navigation.current.route == TRAINLOG_ROUTE_STATS ? 4U : 3U; + size_t count = app->navigation.current.route == TRAINLOG_ROUTE_STATS ? 5U : 3U; if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected; if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected; return; } + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_TRAINING && + (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)) { + app->statistics_graph = key == TRAINLOG_KEY_LEFT + ? (app->statistics_graph + 4U) % 5U + : (app->statistics_graph + 1U) % 5U; + return; + } + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_ZONES && + app->statistics_zone_count > 0U && + (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { + if (key == TRAINLOG_KEY_UP) + app->statistics_zone_selected = app->statistics_zone_selected > 0U + ? app->statistics_zone_selected - 1U + : app->statistics_zone_count - 1U; + else + app->statistics_zone_selected = + (app->statistics_zone_selected + 1U) % app->statistics_zone_count; + return; + } + if (app->navigation.current.route == TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL && + (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT)) { + size_t step; + for (step = 0U; step < TRAINLOG_EXERCISE_SERIES_COUNT; ++step) { + size_t next = key == TRAINLOG_KEY_LEFT + ? ((size_t)app->exercise_statistics_graph + + TRAINLOG_EXERCISE_SERIES_COUNT - 1U) % + TRAINLOG_EXERCISE_SERIES_COUNT + : ((size_t)app->exercise_statistics_graph + 1U) % + TRAINLOG_EXERCISE_SERIES_COUNT; + app->exercise_statistics_graph = (TrainlogExerciseSeriesKind)next; + if (app->exercise_statistics.series_count[next] > 0U) break; + } + return; + } if (app_shell_is_list_route(app->navigation.current.route)) { + if ((key == 'a' || key == 'A') && + app->navigation.current.route == TRAINLOG_ROUTE_MAX) { + app->max_show_all = !app->max_show_all; + app_shell_refresh_list(app); + return; + } if ((key == 'a' || key == 'A') && app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) { exercise_controller_start_create(app, false); @@ -9200,8 +9740,7 @@ shell_nonknowledge_input: return; } } - if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE || - app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) { + if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE) { size_t page = app->layout.content.height > 10 ? (size_t)(app->layout.content.height - 10) : 1U; if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll; @@ -9212,9 +9751,6 @@ shell_nonknowledge_input: else if (key == TRAINLOG_KEY_PAGE_DOWN && app->performance_count > 0U) app->content_scroll = app->content_scroll + page < app->performance_count ? app->content_scroll + page : app->performance_count - 1U; - else if ((key == 'r' || key == 'R') && - app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) - app->max_rounding_index = (app->max_rounding_index + 1U) % 4U; else goto shell_nonanalytics_input; return; } diff --git a/tui/tests/test_chart.c b/tui/tests/test_chart.c new file mode 100644 index 0000000..857b051 --- /dev/null +++ b/tui/tests/test_chart.c @@ -0,0 +1,181 @@ +/** + * @file test_chart.c + * @brief Terminal-free geometry and Braille chart regressions. + */ + +#include "trainlog/chart.h" + +#include +#include +#include +#include + +struct TrainlogSurface { + TrainlogChartRect bounds; + bool overflow; + size_t draw_count; + size_t axis_count; + size_t marker_count; + size_t braille_count; + size_t block_count; + size_t print_count; +}; + +#define CHECK(condition) do { if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ + __FILE__, __LINE__, #condition); return false; } } while (0) + +/* Renderer symbols capture the production component's writes without creating + * an interactive terminal or depending on a particular terminal emulator. */ +void trainlog_surface_set_role(TrainlogSurface *surface, + TrainlogColorRole foreground, unsigned background_rgb, + TrainlogTextStyle style) +{ (void)surface; (void)foreground; (void)background_rgb; (void)style; } +void trainlog_surface_printf(TrainlogSurface *surface, int row, int column, + const char *format, ...) +{ + (void)format; + if (row < surface->bounds.top || column < surface->bounds.left || + row >= surface->bounds.top + surface->bounds.height || + column >= surface->bounds.left + surface->bounds.width) + surface->overflow = true; + ++surface->print_count; +} +void trainlog_surface_draw(TrainlogSurface *surface, int row, int column, + uint32_t codepoint) +{ + if (row < surface->bounds.top || column < surface->bounds.left || + row >= surface->bounds.top + surface->bounds.height || + column >= surface->bounds.left + surface->bounds.width) + surface->overflow = true; + ++surface->draw_count; + if (codepoint == 0x2500U || codepoint == 0x2502U || codepoint == 0x2514U) + ++surface->axis_count; + if (codepoint == 0x25c6U) ++surface->marker_count; + if (codepoint >= 0x2801U && codepoint <= 0x28ffU) + ++surface->braille_count; + if (codepoint == 0x2588U) ++surface->block_count; +} + +static bool test_scales_and_mapping(void) +{ + TrainlogChartScale scale; + TrainlogChartPoint growing[] = { + {0, 83.7, NULL}, {86400, 84.0, NULL}, {691200, 85.1, NULL}}; + TrainlogChartPoint falling[] = {{0, 10.0, NULL}, {10, 5.0, NULL}}; + TrainlogChartPoint constant[] = {{0, 42.0, NULL}, {10, 42.0, NULL}}; + TrainlogChartPoint one[] = {{50, 7.0, NULL}}; + CHECK(!trainlog_chart_scale(NULL, 0U, &scale)); + CHECK(trainlog_chart_scale(one, 1U, &scale)); + CHECK(scale.minimum_value < 7.0 && scale.maximum_value > 7.0); + CHECK(trainlog_chart_map_x(&scale, 50, 20) == 9); + CHECK(trainlog_chart_scale(constant, 2U, &scale)); + CHECK(scale.maximum_value > scale.minimum_value); + CHECK(trainlog_chart_map_y(&scale, 42.0, 17) == 8); + CHECK(trainlog_chart_scale(growing, 3U, &scale)); + CHECK(trainlog_chart_map_x(&scale, 0, 101) == 0); + CHECK(trainlog_chart_map_x(&scale, 86400, 101) == 13); + CHECK(trainlog_chart_map_x(&scale, 691200, 101) == 100); + CHECK(trainlog_chart_map_x(&scale, -100, 101) == 0); + CHECK(trainlog_chart_map_x(&scale, 900000, 101) == 100); + CHECK(trainlog_chart_map_y(&scale, 85.1, 100) < + trainlog_chart_map_y(&scale, 83.7, 100)); + CHECK(trainlog_chart_scale(falling, 2U, &scale)); + CHECK(trainlog_chart_map_y(&scale, 10.0, 20) < + trainlog_chart_map_y(&scale, 5.0, 20)); + return true; +} + +static bool test_invalid_and_small_dimensions(void) +{ + TrainlogChartScale scale = {0, 10, 0.0, 10.0}; + TrainlogChartPoint unordered[] = {{10, 1.0, NULL}, {0, 2.0, NULL}}; + TrainlogChartPoint invalid[] = {{0, NAN, NULL}}; + CHECK(!trainlog_chart_scale(unordered, 2U, &scale)); + CHECK(!trainlog_chart_scale(invalid, 1U, &scale)); + CHECK(trainlog_chart_map_x(&scale, 5, 0) == 0); + CHECK(trainlog_chart_map_y(&scale, 5.0, 1) == 0); + return true; +} + +static bool test_explicit_domains_and_non_negative_scale(void) +{ + TrainlogChartPoint points[] = {{800, 1.0, NULL}, {900, 2.0, NULL}}; + TrainlogChartOptions seven = {true, 300, 1000, true, true}; + TrainlogChartOptions thirty = {true, -2000, 1000, true, true}; + TrainlogChartScale scale7; + TrainlogChartScale scale30; + CHECK(trainlog_chart_scale_with_options(points, 2U, &seven, &scale7)); + CHECK(trainlog_chart_scale_with_options(points, 2U, &thirty, &scale30)); + CHECK(scale7.minimum_timestamp == 300 && scale30.minimum_timestamp == -2000); + CHECK(trainlog_chart_map_x(&scale7, 800, 100) != + trainlog_chart_map_x(&scale30, 800, 100)); + CHECK(scale7.minimum_value == 0.0 && scale30.minimum_value == 0.0); + return true; +} + +static bool test_braille_encoding(void) +{ + uint8_t cell = 0U; + cell = trainlog_chart_braille_dot(cell, 0, 0); + cell = trainlog_chart_braille_dot(cell, 1, 3); + CHECK(cell == 0x81U); + CHECK(trainlog_chart_braille_codepoint(cell) == 0x2881U); + CHECK(trainlog_chart_braille_dot(0U, 1, 0) == 0x08U); + CHECK(trainlog_chart_braille_dot(0U, 0, 3) == 0x40U); + CHECK(trainlog_chart_braille_dot(0x01U, 2, 0) == 0x01U); + return true; +} + +static bool test_rendering_is_bounded(void) +{ + TrainlogSurface surface; + TrainlogChartRect rect = {3, 4, 9, 52}; + TrainlogChartPoint points[] = { + {1788739200, 83.7, "2026-09-07T00:00:00+02:00"}, + {1788998400, 85.1, "2026-09-10T00:00:00+02:00"}, + {1789430400, 84.2, "2026-09-15T00:00:00+02:00"} + }; + (void)memset(&surface, 0, sizeof(surface)); surface.bounds = rect; + trainlog_chart_render(&surface, rect, NULL, 0U, "kg", "Poids"); + CHECK(!surface.overflow && surface.draw_count == 0U && + surface.print_count >= 2U); + + (void)memset(&surface, 0, sizeof(surface)); surface.bounds = rect; + trainlog_chart_render(&surface, rect, points, 1U, "kg", "Poids"); + CHECK(!surface.overflow && surface.marker_count == 1U && + surface.axis_count > 0U && surface.braille_count > 0U); + + (void)memset(&surface, 0, sizeof(surface)); surface.bounds = rect; + trainlog_chart_render(&surface, rect, points, 2U, "kg", "Poids"); + CHECK(!surface.overflow && surface.marker_count == 2U && + surface.braille_count > 0U); + + /* The compact fallback still stays inside an arbitrarily placed small + * rectangle; it cannot leak into sibling planes or the footer. */ + rect = (TrainlogChartRect){7, 9, 3, 12}; + (void)memset(&surface, 0, sizeof(surface)); surface.bounds = rect; + trainlog_chart_render(&surface, rect, points, 3U, "kg", "Poids"); + CHECK(!surface.overflow && surface.draw_count == 0U && + surface.print_count >= 2U); + + rect = (TrainlogChartRect){3, 4, 9, 52}; + (void)memset(&surface, 0, sizeof(surface)); surface.bounds = rect; + { + TrainlogChartOptions options = {true, 1788134400, 1789430400, true, true}; + trainlog_chart_render_bars(&surface, rect, points, 2U, "kg", + "Volume hebdomadaire", &options); + } + CHECK(!surface.overflow && surface.block_count > 0U && + surface.braille_count == 0U && surface.marker_count == 0U); + return true; +} + +int main(void) +{ + if (!test_scales_and_mapping() || !test_invalid_and_small_dimensions() || + !test_explicit_domains_and_non_negative_scale() || + !test_braille_encoding() || !test_rendering_is_bounded()) return 1; + (void)printf("PASS chart\n"); + return 0; +} diff --git a/tui/tests/test_statistics.c b/tui/tests/test_statistics.c new file mode 100644 index 0000000..8b41916 --- /dev/null +++ b/tui/tests/test_statistics.c @@ -0,0 +1,359 @@ +/** + * @file test_statistics.c + * @brief Read-model regressions for factual desktop statistics. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "trainlog/database.h" +#include "trainlog/statistics.h" + +#define NOW_UTC INT64_C(1789473600) + +#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 exec_sql(sqlite3 *database, const char *sql) +{ + char *error = NULL; + int status = sqlite3_exec(database, sql, NULL, NULL, &error); + if (status != SQLITE_OK) { + (void)fprintf(stderr, "SQLite fixture error: %s\n", + error != NULL ? error : "unknown"); + sqlite3_free(error); + return false; + } + return true; +} + +static void fixture_profile_revision(sqlite3_context *context, int count, + sqlite3_value **values) +{ + static unsigned sequence = 0U; + char value[32]; + (void)count; (void)values; + ++sequence; + (void)snprintf(value, sizeof(value), "pr_fixture_%u", sequence); + sqlite3_result_text(context, value, -1, SQLITE_TRANSIENT); +} + +static bool build_fixture(const char *path) +{ + TrainlogDatabase *database = NULL; + sqlite3 *raw = NULL; + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + trainlog_database_close(database); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_create_function(raw, "trainlog_profile_revision", -1, + SQLITE_UTF8, NULL, fixture_profile_revision, NULL, NULL) == SQLITE_OK); + CHECK(exec_sql(raw, + "PRAGMA foreign_keys=ON;" + "INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_semantics) VALUES" + "('ex_stat_load','Charge canonique','charge canonique','reps','sets',0,'external')," + "('ex_stat_alias','Ancien alias','ancien alias','reps','sets',0,'external')," + "('ex_stat_cont','Continu','continu','duration','continuous',0,'cardio');" + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','primary' FROM exercises WHERE exercise_id='ex_stat_load';" + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','secondary' FROM exercises WHERE exercise_id='ex_stat_load';" + "INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'back','primary' FROM exercises WHERE exercise_id='ex_stat_cont';" + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES" + "('se_stat_1','2026-09-10T10:00:00Z','2026-09-10T11:00:00Z','training')," + "('se_stat_2','2026-09-14T10:00:00Z','2026-09-14T10:30:00Z','training')," + "('se_stat_old','2026-06-17T10:00:00Z','2026-06-17T10:20:00Z','training')," + "('se_stat_max','2026-09-15T10:00:00Z','2026-09-15T10:10:00Z','max_test');")); + CHECK(exec_sql(raw, + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_weight_kg) " + "SELECT 'sxe_stat_load',s.id,e.id,'sets','reps',0,0,'external',60,2,10,50 FROM sessions s,exercises e WHERE s.session_id='se_stat_1' AND e.exercise_id='ex_stat_load';" + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) " + "SELECT 'sxe_stat_cont',s.id,e.id,'continuous','duration',0,1,'none',0 FROM sessions s,exercises e WHERE s.session_id='se_stat_1' AND e.exercise_id='ex_stat_cont';" + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_weight_kg) " + "SELECT 'sxe_stat_alias',s.id,e.id,'sets','reps',0,0,'external',60,1,12,60 FROM sessions s,exercises e WHERE s.session_id='se_stat_2' AND e.exercise_id='ex_stat_alias';" + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,target_weight_kg) " + "SELECT 'sxe_stat_old',s.id,e.id,'sets','reps',0,0,'external',60,1,5,5 FROM sessions s,exercises e WHERE s.session_id='se_stat_old' AND e.exercise_id='ex_stat_load';" + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) " + "SELECT 'sxe_stat_max',s.id,e.id,'sets','reps',0,0,'external',60 FROM sessions s,exercises e WHERE s.session_id='se_stat_max' AND e.exercise_id='ex_stat_load';" + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,10,50 FROM session_exercises WHERE entry_id='sxe_stat_load';" + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,1,8,50 FROM session_exercises WHERE entry_id='sxe_stat_load';" + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,12,60 FROM session_exercises WHERE entry_id='sxe_stat_alias';" + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,5,5 FROM session_exercises WHERE entry_id='sxe_stat_old';" + "INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds) SELECT id,1800 FROM session_exercises WHERE entry_id='sxe_stat_cont';" + "INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,100 FROM session_exercises WHERE entry_id='sxe_stat_max';" + "INSERT INTO exercise_feedback(feedback_id,session_exercise_row_id,observed_at,raw_text) SELECT 'fb_stat',id,'2026-09-10T11:01:00Z','texte libre' FROM session_exercises WHERE entry_id='sxe_stat_load';" + "INSERT INTO session_followups(followup_id,session_row_id,observed_at,raw_text) SELECT 'fu_stat',id,'2026-09-11T08:00:00Z','texte libre global' FROM sessions WHERE session_id='se_stat_1';")); + CHECK(sqlite3_close(raw) == SQLITE_OK); + return true; +} + +static bool test_empty(void) +{ + TrainlogDatabase *database = NULL; + TrainlogStatisticsSummary summary; + TrainlogZoneStatistics zones[4]; + size_t count = 99U; + bool partial = true; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + for (TrainlogStatisticsWindow window = TRAINLOG_STATISTICS_7_DAYS; + window <= TRAINLOG_STATISTICS_ALL; ++window) { + CHECK(trainlog_statistics_load_summary(database, window, + NOW_UTC, &summary) == TRAINLOG_STATUS_OK); + CHECK(summary.sessions == 0U && summary.sets == 0U && + summary.loaded_volume_kg == 0.0 && summary.bucket_count == 0U); + } + CHECK(trainlog_statistics_load_zones(database, NOW_UTC, zones, 4U, + &count, &partial) == TRAINLOG_STATUS_OK); + CHECK(count == 0U && !partial); + CHECK(trainlog_statistics_load_exercise(database, "ex_absent", NOW_UTC, + &(TrainlogExerciseStatistics){0}) == TRAINLOG_STATUS_NOT_FOUND); + trainlog_database_close(database); + return true; +} + +static bool test_calendar_boundaries(void) +{ + char path[] = "/tmp/trainlog-statistics-calendar-XXXXXX"; + int descriptor = mkstemp(path); + TrainlogDatabase *database = NULL; + TrainlogStatisticsSummary summary; + sqlite3 *raw = NULL; + const int64_t march_2024_now = INT64_C(1709726400); /* 06/03/2024 13:00 CET */ + const int64_t april_2024_now = INT64_C(1713175200); /* 15/04/2024 12:00 CEST */ + const int64_t march_2023_now = INT64_C(1678881600); /* 15/03/2023 13:00 CET */ + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(build_fixture(path)); + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_create_function(raw, "trainlog_profile_revision", -1, + SQLITE_UTF8, NULL, fixture_profile_revision, NULL, NULL) == SQLITE_OK); + CHECK(exec_sql(raw, + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES" + "('se_cal_2023_feb_first','2023-02-01T08:00:00+14:00',NULL,'training')," + "('se_cal_2023_feb_last','2023-02-28T23:00:00-10:00',NULL,'training')," + "('se_cal_2023_mar_first','2023-03-01T08:00:00Z',NULL,'training')," + "('se_cal_2024_feb_first','2024-02-01T08:00:00Z',NULL,'training')," + "('se_cal_2024_feb_last','2024-02-29T08:00:00Z',NULL,'training')," + "('se_cal_sunday','2024-03-03T08:00:00Z',NULL,'training')," + "('se_cal_monday','2024-03-04T08:00:00Z',NULL,'training')," + "('se_cal_april','2024-04-15T08:00:00Z',NULL,'training');" + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) " + "SELECT 'sxe_'||s.session_id,s.id,e.id,'sets','reps',0,0,'external',0 FROM sessions s,exercises e WHERE s.session_id LIKE 'se_cal_%' AND e.exercise_id='ex_stat_load';" + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,1,1 FROM session_exercises WHERE entry_id LIKE 'sxe_se_cal_%';")); + CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_7_DAYS, + march_2024_now, &summary) == TRAINLOG_STATUS_OK); + CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_7_DAYS); + CHECK(summary.bucket_count >= 2U); + CHECK(strcmp(summary.buckets[summary.bucket_count - 2U].label, + "26/02-03/03") == 0); + CHECK(summary.buckets[summary.bucket_count - 2U].sessions == 2U); + CHECK(strcmp(summary.buckets[summary.bucket_count - 1U].label, + "04/03-10/03") == 0); + CHECK(summary.buckets[summary.bucket_count - 1U].sessions == 1U); + CHECK(summary.buckets[summary.bucket_count - 1U].end_timestamp - + summary.buckets[summary.bucket_count - 1U].timestamp == + INT64_C(7) * INT64_C(86400)); + + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_30_DAYS, + april_2024_now, &summary) == TRAINLOG_STATUS_OK); + CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH); + CHECK(summary.bucket_count == 3U); + CHECK(strcmp(summary.buckets[0].label, "févr. 2024") == 0 && + summary.buckets[0].sessions == 2U); + CHECK(strcmp(summary.buckets[1].label, "mars 2024") == 0 && + summary.buckets[1].sessions == 2U); + CHECK(strcmp(summary.buckets[2].label, "avr. 2024") == 0 && + summary.buckets[2].sessions == 1U); + CHECK(summary.buckets[0].end_timestamp - summary.buckets[0].timestamp == + INT64_C(29) * INT64_C(86400)); + CHECK(summary.buckets[1].end_timestamp - summary.buckets[1].timestamp == + INT64_C(31) * INT64_C(86400)); + + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_30_DAYS, + march_2023_now, &summary) == TRAINLOG_STATUS_OK); + CHECK(summary.bucket_count == 2U); + CHECK(strcmp(summary.buckets[0].label, "févr. 2023") == 0 && + summary.buckets[0].sessions == 2U); + CHECK(summary.buckets[0].end_timestamp - summary.buckets[0].timestamp == + INT64_C(28) * INT64_C(86400)); + CHECK(strcmp(summary.buckets[1].label, "mars 2023") == 0 && + summary.buckets[1].sessions == 1U); + + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return true; +} + +static bool test_aggregates(void) +{ + char path[] = "/tmp/trainlog-statistics-XXXXXX"; + int descriptor = mkstemp(path); + TrainlogDatabase *database = NULL; + TrainlogStatisticsSummary summary; + TrainlogExerciseStatistics exercise; + TrainlogZoneStatistics zones[16]; + TrainlogMaxListItem maxima[16]; + TrainlogExerciseListStatistic exercise_list[16]; + size_t maximum_count = 0U; + size_t exercise_list_count = 0U; + size_t zone_count = 0U; + bool partial = false; + size_t index; + CHECK(descriptor >= 0 && close(descriptor) == 0); + CHECK(build_fixture(path)); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + /* Alias reconciliation repoints the persisted occurrence; statistics then + * expose only the canonical identity, never the retired display name. */ + CHECK(trainlog_database_merge_exercises(database, "ex_stat_alias", + "ex_stat_load") == TRAINLOG_STATUS_OK); + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_7_DAYS, + NOW_UTC, &summary) == TRAINLOG_STATUS_OK); + CHECK(summary.sessions == 2U && summary.distinct_exercises == 1U); + CHECK(summary.occurrences == 2U && summary.sets == 1U); + CHECK(summary.repetitions == 12U && summary.activity_duration_seconds == 0U); + CHECK(summary.session_duration_seconds == 2400U && + summary.sessions_with_duration == 2U); + CHECK(fabs(summary.loaded_volume_kg - 720.0) < 0.001); + CHECK(summary.active_days == 2U && summary.active_weeks == 1U); + CHECK(summary.days_since_last_session == 0U); + CHECK(summary.immediate_feedback_count == 0U && summary.followup_count == 0U); + CHECK(summary.top_feedback_count == 0U); + CHECK(summary.planned_actual_occurrences == 1U && + summary.planned_sets == 1U && summary.actual_sets_for_plans == 1U); + CHECK(summary.planned_repetitions == 12U && + summary.actual_repetitions_for_plans == 12U); + CHECK(summary.bucket_count >= 1U); + CHECK(summary.bucket_count == 4U); + CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_7_DAYS); + CHECK(strcmp(summary.buckets[0].label, "24/08-30/08") == 0); + CHECK(strcmp(summary.buckets[summary.bucket_count - 1U].label, + "14/09-20/09") == 0); + CHECK(summary.buckets[2].sessions == 1U && + summary.buckets[3].sessions == 2U); + { + size_t bucket_sessions = 0U; + for (index = 0U; index < summary.bucket_count; ++index) + bucket_sessions += summary.buckets[index].sessions; + CHECK(bucket_sessions == 3U); + } + + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_30_DAYS, + NOW_UTC, &summary) == TRAINLOG_STATUS_OK && summary.sessions == 3U); + CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH); + CHECK(summary.bucket_count == 4U); + CHECK(strcmp(summary.buckets[0].label, "juin 2026") == 0); + CHECK(strcmp(summary.buckets[1].label, "juil. 2026") == 0); + CHECK(strcmp(summary.buckets[2].label, "août 2026") == 0); + CHECK(strcmp(summary.buckets[3].label, "sept. 2026") == 0); + CHECK(summary.buckets[0].sessions == 1U); + CHECK(summary.buckets[1].sessions == 0U && + summary.buckets[2].sessions == 0U); + CHECK(summary.buckets[3].sessions == 3U); + { + size_t bucket_sessions = 0U; + TrainlogStatisticsSummary repeated; + for (index = 0U; index < summary.bucket_count; ++index) + bucket_sessions += summary.buckets[index].sessions; + /* The persisted 17/06 date belongs to June exactly once. */ + CHECK(bucket_sessions == 4U); + CHECK(trainlog_statistics_load_summary(database, + TRAINLOG_STATISTICS_30_DAYS, NOW_UTC, &repeated) == + TRAINLOG_STATUS_OK); + CHECK(repeated.bucket_count == summary.bucket_count && + memcmp(repeated.buckets, summary.buckets, + summary.bucket_count * sizeof(summary.buckets[0])) == 0); + } + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_ALL, + NOW_UTC, &summary) == TRAINLOG_STATUS_OK && summary.sessions == 4U && + summary.repetitions == 35U); + CHECK(summary.bucket_kind == TRAINLOG_STATISTICS_BUCKET_7_DAYS); + { + bool has_zero_week = false; + for (index = 0U; index < summary.bucket_count; ++index) + if (summary.buckets[index].sessions == 0U) has_zero_week = true; + CHECK(has_zero_week); + } + + CHECK(trainlog_statistics_load_zones(database, NOW_UTC, zones, 16U, + &zone_count, &partial) == TRAINLOG_STATUS_OK && !partial); + for (index = 0U; index < zone_count; ++index) { + if (strcmp(zones[index].zone_id, "chest") == 0) + CHECK(zones[index].primary_7 == 3U && + zones[index].secondary_7 == 0U); + if (strcmp(zones[index].zone_id, "arms") == 0) + CHECK(zones[index].secondary_30 == 3U); + if (strcmp(zones[index].zone_id, "back") == 0) + CHECK(zones[index].primary_7 == 1U); + } + + CHECK(trainlog_statistics_load_exercise(database, "ex_stat_load", NOW_UTC, + &exercise) == TRAINLOG_STATUS_OK); + CHECK(exercise.occurrences == 4U && exercise.sessions == 4U); + CHECK(exercise.frequency_7 == 3U && exercise.frequency_30 == 3U); + CHECK(exercise.sets == 4U && exercise.repetitions == 35U); + CHECK(fabs(exercise.loaded_volume_kg - 1645.0) < 0.001); + CHECK(exercise.has_highest_load && exercise.highest_load_kg == 60.0); + CHECK(exercise.has_repetitions_at_load && exercise.highest_repetitions == 12 && + exercise.highest_repetitions_load_kg == 60.0); + CHECK(exercise.max_count == 1U && exercise.latest_max_kg == 100.0 && + !exercise.has_previous_max); + CHECK(exercise.immediate_feedback_count == 1U); + CHECK(exercise.has_latest_planned_actual_weight && + exercise.latest_planned_weight_kg == 60.0 && + exercise.latest_actual_weight_kg == 60.0); + CHECK(exercise.series_count[TRAINLOG_EXERCISE_SERIES_MAX] == 1U); + CHECK(trainlog_statistics_load_exercise(database, "ex_stat_alias", NOW_UTC, + &exercise) == TRAINLOG_STATUS_NOT_FOUND); + CHECK(trainlog_statistics_list_maxima(database, maxima, 16U, + &maximum_count, &partial) == TRAINLOG_STATUS_OK && !partial && + maximum_count == 1U && strcmp(maxima[0].exercise_id, "ex_stat_load") == 0 && + maxima[0].latest_max_kg == 100.0); + CHECK(trainlog_statistics_list_exercises(database, exercise_list, 16U, + &exercise_list_count, &partial) == TRAINLOG_STATUS_OK && !partial && + exercise_list_count == 2U); + /* A sparse old observation still expands total duration. Adaptive choice + * is duration-based, not dependent on the number of active weeks. */ + { + sqlite3 *raw = NULL; + TrainlogStatisticsSummary repeated; + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(sqlite3_create_function(raw, "trainlog_profile_revision", -1, + SQLITE_UTF8, NULL, fixture_profile_revision, NULL, NULL) == SQLITE_OK); + CHECK(exec_sql(raw, + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES" + "('se_stat_ancient','2020-01-01T10:00:00Z','2020-01-01T10:10:00Z','training');" + "INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,data_fields,position,load_mode,rest_seconds) " + "SELECT 'sxe_stat_ancient',s.id,e.id,'sets','reps',0,0,'external',0 FROM sessions s,exercises e WHERE s.session_id='se_stat_ancient' AND e.exercise_id='ex_stat_load';" + "INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) SELECT id,0,1,1 FROM session_exercises WHERE entry_id='sxe_stat_ancient';")); + CHECK(sqlite3_close(raw) == SQLITE_OK); + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_ALL, + NOW_UTC, &summary) == TRAINLOG_STATUS_OK); + CHECK(summary.bucket_kind == + TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH); + CHECK(trainlog_statistics_load_summary(database, TRAINLOG_STATISTICS_ALL, + NOW_UTC, &repeated) == TRAINLOG_STATUS_OK); + CHECK(repeated.bucket_kind == summary.bucket_kind && + repeated.bucket_count == summary.bucket_count); + } + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return true; +} + +int main(void) +{ + /* The production contract uses the user's local civil date. Pin the test + * locale so exact bucket-boundary fixtures are deterministic in CI. */ + if (setenv("TZ", "Europe/Paris", 1) != 0) return 1; + tzset(); + if (!test_empty() || !test_calendar_boundaries() || !test_aggregates()) + return 1; + (void)printf("PASS statistics\n"); + return 0; +} diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 4fc580d..c8062f5 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -697,6 +697,7 @@ static bool test_shell_actions_overlay_executes_registered_search_action(void) trainlog_navigation_init(&app.navigation); app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT; trainlog_search_init(&app.search); + app.exercise_zone_filter = -1; app_shell_refresh_list(&app); app_shell_actions(&app); search_action = trainlog_actions_find_key(&app.actions, '/'); @@ -1530,6 +1531,10 @@ static bool test_stats_dashboard_uses_real_exact_time_snapshots_responsively(voi "ex_77777777-7777-4777-8777-777777777777", "Marche test", "marche test", TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS, (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + "ex_99999999-9999-4999-8999-999999999999", "Sans MAX", + "sans max", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, + (TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK); /* Raw text says 23:00 is later, but +15:00 makes it the older instant. */ CHECK(dashboard_insert_completed_set(database, "se_11111111-1111-4111-8111-111111111111", @@ -1597,6 +1602,25 @@ static bool test_stats_dashboard_uses_real_exact_time_snapshots_responsively(voi CHECK(app.dashboard.performed_set_count == 4U); CHECK(app.dashboard.distinct_exercise_count == 2U); CHECK(app.dashboard.explicit_max_count == 1U); + trainlog_list_init(&app.list); + trainlog_search_init(&app.search); + app.exercise_zone_filter = -1; + app.navigation.current.route = TRAINLOG_ROUTE_MAX; + app_shell_refresh_list(&app); + CHECK(!app.list_error && app.loaded_count == 1U); + CHECK(strcmp(app.exercises[0].exercise_id, + "ex_33333333-3333-4333-8333-333333333333") == 0); + app.max_show_all = true; + app_shell_refresh_list(&app); + CHECK(!app.list_error && app.loaded_count > 1U); + { + bool has_never_measured = false; + for (size_t exercise = 0U; exercise < app.loaded_count; ++exercise) + if (strcmp(app.exercises[exercise].exercise_id, + "ex_99999999-9999-4999-8999-999999999999") == 0) + has_never_measured = true; + CHECK(has_never_measured); + } (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; @@ -1652,14 +1676,17 @@ static bool test_stats_dashboard_uses_real_exact_time_snapshots_responsively(voi CHECK(strstr(terminal.output, "Fréquence") != NULL); CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); trainlog_navigation_init(&app.navigation); - app.navigation.current.route = TRAINLOG_ROUTE_STATS; + app.navigation.current.route = TRAINLOG_ROUTE_STATS_TRAINING; + app.layout.usable = true; + app.focus = TRAINLOG_FOCUS_CONTENT; app.layout.usable = true; app.focus = TRAINLOG_FOCUS_CONTENT; app_shell_dispatch(&app, '1'); - CHECK(app.dashboard.period == TRAINLOG_STATS_7_DAYS); + CHECK(app.statistics_window == TRAINLOG_STATISTICS_7_DAYS); + app.navigation.current.route = TRAINLOG_ROUTE_STATS; app.content_selected = 2U; app_shell_primary(&app); - CHECK(app.navigation.current.route == TRAINLOG_ROUTE_SESSIONS_COMPLETED); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_STATS_EXERCISE); trainlog_database_close(database); return true; } @@ -1921,8 +1948,8 @@ static bool test_stats_detail_charts_and_global_zone_projection(void) dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16); /* The sparse-state contract is deliberately exercised through the exact - * detail painters: empty and singleton series have prose only, while two - * real observations acquire Unicode points/segments rather than ASCII. */ + * detail painters. Measurement charts keep a real singleton marker and + * two observations acquire Braille interpolation rather than ASCII. */ app_shell_draw_performance_graph(&app, NULL, TRAINLOG_LOAD_EXTERNAL, TRAINLOG_TRACKING_REPS, 5, 4, false); CHECK(strstr(terminal.output, "Aucun point exploitable") != NULL); @@ -1956,21 +1983,20 @@ static bool test_stats_detail_charts_and_global_zone_projection(void) (void)memset(body, 0, sizeof(body)); (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; - app_shell_draw_body_points(&app, body, 0U, 5, 4, "kg"); + app_shell_draw_body_points(&app, body, 0U, 5, 9, "kg", "Évolution"); CHECK(strstr(terminal.output, "Aucune donnée") != NULL); body[0].value = 80.0; (void)snprintf(body[0].observed_at, sizeof(body[0].observed_at), "%s", "2026-09-01T08:00:00Z"); (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; - app_shell_draw_body_points(&app, body, 1U, 5, 4, "kg"); - CHECK(strstr(terminal.output, "1 relevé") != NULL); - CHECK(terminal.surface_draw_count == 0U); + app_shell_draw_body_points(&app, body, 1U, 5, 9, "kg", "Évolution"); + CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0); body[1] = body[0]; body[1].value = 79.0; (void)snprintf(body[1].observed_at, sizeof(body[1].observed_at), "%s", "2026-09-02T08:00:00Z"); (void)memset(&terminal, 0, sizeof(terminal)); surface.terminal = &terminal; - app_shell_draw_body_points(&app, body, 2U, 5, 4, "kg"); - CHECK(rendered_draw_column(&terminal, 0x25cfU, 0U) >= 0); + app_shell_draw_body_points(&app, body, 2U, 5, 9, "kg", "Évolution"); + CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0); CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_insert_exercise(database, @@ -2136,9 +2162,9 @@ static bool test_stats_detail_charts_and_global_zone_projection(void) } trainlog_navigation_init(&app.navigation); app.navigation.current.route = TRAINLOG_ROUTE_STATS; - app.content_selected = 3U; + app.content_selected = 2U; app_shell_primary(&app); - CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EXERCISES); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_STATS_EXERCISE); trainlog_database_close(database); return true; } @@ -2203,7 +2229,7 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void) CHECK(strstr(terminal.output, "Tour de taille") != NULL); CHECK(strstr(terminal.output, "85.0 cm") != NULL); CHECK(strstr(terminal.output, "Épaules") == NULL); - CHECK(strstr(terminal.output, "1 relevé · tendance indisponible") != NULL); + CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0); CHECK(rendered_draw_column(&terminal, 0x2586U, 0U) >= 0); for (drawn = 0U; drawn < terminal.drawn_count; ++drawn) { CHECK(terminal.drawn[drawn].row != 4); @@ -2223,9 +2249,9 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void) "2026-08-10T08:00:00Z"); dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20); app_shell_render_body_profile(&app); - CHECK(strstr(terminal.output, "2026-08-10") != NULL); - CHECK(strstr(terminal.output, "2026-09-10") != NULL); - CHECK(rendered_draw_column(&terminal, 0x25cfU, 0U) >= 0); + CHECK(strstr(terminal.output, "10/08") != NULL); + CHECK(strstr(terminal.output, "10/09") != NULL); + CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0); app_shell_dispatch(&app, TRAINLOG_KEY_DOWN); CHECK(app.list.selected_index == 1U); @@ -2249,6 +2275,100 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void) return true; } +static bool test_statistics_center_renders_and_cycles_factual_views(void) +{ + TrainlogAppContext app; + TrainlogTerminal terminal; + TrainlogSurface surface; + (void)memset(&app, 0, sizeof(app)); + (void)memset(&surface, 0, sizeof(surface)); + dashboard_prepare_render(&app, &terminal, &surface, 100, 30, 96, 24); + app.statistics_window = TRAINLOG_STATISTICS_30_DAYS; + app.statistics.sessions = 2U; + app.statistics.distinct_exercises = 1U; + app.statistics.occurrences = 2U; + app.statistics.sets = 6U; + app.statistics.repetitions = 60U; + app.statistics.active_days = 2U; + app.statistics.active_weeks = 2U; + app.statistics.loaded_volume_kg = 3200.0; + app.statistics.bucket_kind = TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH; + app.statistics.bucket_count = 2U; + app.statistics.buckets[0].timestamp = 1000; + app.statistics.buckets[0].loaded_volume_kg = 1200.0; + app.statistics.buckets[0].sessions = 1U; + app.statistics.buckets[0].sets = 2U; + app.statistics.buckets[1].timestamp = 2000; + app.statistics.buckets[1].loaded_volume_kg = 2000.0; + app.statistics.buckets[1].sessions = 1U; + app.statistics.buckets[1].sets = 4U; + app_shell_render_statistics_training(&app); + CHECK(strstr(terminal.output, "ACTIVITÉ") != NULL); + CHECK(strstr(terminal.output, "TRAVAIL") != NULL); + CHECK(strstr(terminal.output, "Volume 3200.0 kg") != NULL); + CHECK(strstr(terminal.output, + "Volume chargé · mois calendaires") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_STATS_TRAINING; + app.layout.usable = true; + app.focus = TRAINLOG_FOCUS_CONTENT; + app_shell_dispatch(&app, TRAINLOG_KEY_RIGHT); + CHECK(app.statistics_graph == 1U); + app_shell_dispatch(&app, TRAINLOG_KEY_LEFT); + CHECK(app.statistics_graph == 0U); + + dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 76, 18); + app.statistics_zone_count = 2U; + (void)snprintf(app.statistics_zones[0].label, + sizeof(app.statistics_zones[0].label), "%s", "Dos"); + app.statistics_zones[0].primary_7 = 3U; + app.statistics_zones[0].secondary_7 = 1U; + app.statistics_zones[0].primary_30 = 1U; + app.statistics_zones[0].secondary_30 = 2U; + (void)snprintf(app.statistics_zones[1].label, + sizeof(app.statistics_zones[1].label), "%s", "Bras"); + app.statistics_zones[1].primary_30 = 2U; + app.statistics_zones[1].secondary_30 = 4U; + app_shell_render_statistics_zones(&app); + CHECK(strstr(terminal.output, "principal") != NULL); + CHECK(strstr(terminal.output, "secondaire") != NULL); + { + int zone0_end = -1; + int zone1_end = -1; + for (size_t drawn = 0U; drawn < terminal.drawn_count; ++drawn) { + if (terminal.drawn[drawn].codepoint != 0x2588U) continue; + if (terminal.drawn[drawn].row == 9 && + terminal.drawn[drawn].column > zone0_end) + zone0_end = terminal.drawn[drawn].column; + if (terminal.drawn[drawn].row == 11 && + terminal.drawn[drawn].column > zone1_end) + zone1_end = terminal.drawn[drawn].column; + } + CHECK(zone0_end >= 0 && zone1_end > zone0_end); + } + + dashboard_prepare_render(&app, &terminal, &surface, 100, 30, 96, 24); + (void)snprintf(app.exercise_statistics.name, + sizeof(app.exercise_statistics.name), "%s", "Développé couché"); + (void)snprintf(app.exercise_statistics.exercise_id, + sizeof(app.exercise_statistics.exercise_id), "%s", "ex_stats"); + (void)snprintf(app.exercise_statistics.load_semantics, + sizeof(app.exercise_statistics.load_semantics), "%s", "external"); + app.exercise_statistics.occurrences = 2U; + app.exercise_statistics.sessions = 2U; + app.exercise_statistics.series_count[TRAINLOG_EXERCISE_SERIES_LOAD] = 1U; + app.exercise_statistics.series[TRAINLOG_EXERCISE_SERIES_LOAD][0].timestamp = 1000; + app.exercise_statistics.series[TRAINLOG_EXERCISE_SERIES_LOAD][0].value = 50.0; + app.exercise_statistics_graph = TRAINLOG_EXERCISE_SERIES_LOAD; + app_shell_render_exercise_statistics(&app); + CHECK(strstr(terminal.output, "Développé couché") != NULL); + CHECK(strstr(terminal.output, "Charge observée") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + return true; +} + int main(void) { if (!test_knowledge_scrolls_long_lists_at_minimum_terminal() || @@ -2277,6 +2397,7 @@ int main(void) !test_stats_dashboard_exact_dose_strict_later_and_invalid_rows() || !test_body_graphs_draw_inside_minimum_content_surface() || !test_body_profile_snapshot_and_evolution_are_separate() || + !test_statistics_center_renders_and_cycles_factual_views() || !test_stats_detail_charts_and_global_zone_projection()) return 1; (void)printf("PASS tui_workflows\n"); return 0;