feat: add calendar-based TUI training statistics

This commit is contained in:
fy59 2026-09-15 07:42:48 +02:00
parent c15fdb53b3
commit 89abf531a3
17 changed files with 3147 additions and 175 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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
```

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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

View file

@ -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 <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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

View file

@ -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',

View file

@ -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;

425
tui/src/chart.c Normal file
View file

@ -0,0 +1,425 @@
/**
* @file chart.c
* @brief Time-scaled high-resolution Braille chart renderer.
*/
#include "trainlog/chart.h"
#include <float.h>
#include <limits.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#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);
}
}
}

859
tui/src/statistics.c Normal file
View file

@ -0,0 +1,859 @@
/**
* @file statistics.c
* @brief Read-only SQLite aggregation for auditable TUI statistics.
*/
#include "trainlog/statistics.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#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, &current_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, &current_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;
}

File diff suppressed because it is too large Load diff

181
tui/tests/test_chart.c Normal file
View file

@ -0,0 +1,181 @@
/**
* @file test_chart.c
* @brief Terminal-free geometry and Braille chart regressions.
*/
#include "trainlog/chart.h"
#include <math.h>
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
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;
}

359
tui/tests/test_statistics.c Normal file
View file

@ -0,0 +1,359 @@
/**
* @file test_statistics.c
* @brief Read-model regressions for factual desktop statistics.
*/
#include <sqlite3.h>
#include <math.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#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;
}

View file

@ -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;