feat: add calendar-based TUI training statistics
This commit is contained in:
parent
c15fdb53b3
commit
89abf531a3
17 changed files with 3147 additions and 175 deletions
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -127,6 +127,50 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### Added
|
||||
|
||||
- `TRAINLOG_TUI_STATISTICS_CALENDAR_BUCKETS_V1=PASS`: Training `7j` now means
|
||||
the current Monday–Sunday calendar week and compares calendar weeks; `30j`
|
||||
means the current Gregorian month and compares calendar months. Membership
|
||||
follows the civil date persisted in each timestamp, empty intermediate
|
||||
periods remain zero bars, localized labels expose the calendar boundaries,
|
||||
and exact month endpoints cover ordinary and leap-year February.
|
||||
|
||||
- `TRAINLOG_TUI_STATISTICS_PERIOD_BUCKET_SIZE_FIX=PASS`: the selected Training
|
||||
period now controls both summary depth and comparison-bucket size. `7j` uses
|
||||
exact consecutive 7-day buckets, `30j` uses exact consecutive 30-day buckets
|
||||
anchored on the current local date, and `Tout` selects one stable weekly or
|
||||
calendar-month scale from total historical duration. Empty intervals remain
|
||||
explicit zero bars and boundary observations are counted once.
|
||||
|
||||
- `TRAINLOG_TUI_STATISTICS_PERIOD_BUCKETING_V1=PASS`: Training summaries keep
|
||||
their rolling 7-day/30-day facts while charts compare consecutive exact
|
||||
Monday–Sunday buckets. The 7-day view shows up to four available weeks, the
|
||||
30-day view shows all intersecting weeks, empty periods remain zero bars, and
|
||||
complete history adapts from weekly to monthly totals beyond 128 active
|
||||
weeks without sampling away totals.
|
||||
|
||||
- `TRAINLOG_TUI_STATISTICS_REFINEMENT_V1=PASS`: finite statistics windows now
|
||||
retain their complete time domain; weekly totals use local Monday buckets,
|
||||
explicit empty weeks, zero baselines and discrete Notcurses bars. Wide
|
||||
training summaries are grouped into semantic panels, zone bars expose raw
|
||||
counts on one shared scale, exercise lists show factual summaries, and MAX
|
||||
defaults to exercises with an explicit recorded result while preserving an
|
||||
opt-in all-compatible view and prefix search.
|
||||
|
||||
- `TRAINLOG_TUI_STATISTICS_V1=PASS`: Statistiques is now the desktop analysis
|
||||
center for body history, factual global training activity, canonical
|
||||
exercise histories, persisted primary/secondary body-zone activity and
|
||||
explicit MAX history. Read-only C17 aggregations feed responsive Notcurses
|
||||
views and the shared Braille chart; valid loaded volume, planned/actual and
|
||||
feedback ownership rules deliberately exclude unsupported inference. No
|
||||
Android, database-schema, synchronization or exchange-format behavior changed.
|
||||
|
||||
- `TRAINLOG_TUI_MEASUREMENTS_GRAPH_V1=PASS`: Statistiques / Mensurations now
|
||||
uses a reusable Notcurses chart with true elapsed-time spacing, labelled
|
||||
padded value scales, a 2x4 Unicode Braille line raster, distinct accent
|
||||
markers for stored observations, responsive sparse-profile layout, and
|
||||
terminal-free geometry/raster regressions. No database, measurement, Android,
|
||||
or synchronization semantics changed.
|
||||
|
||||
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`: a shared seven-root
|
||||
application shell—Accueil, Séances, Exercices, Équipements, Statistiques,
|
||||
Synchronisation and Paramètres—on the Notcurses TUI and Android Material 3
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
94
docs/tui.md
94
docs/tui.md
|
|
@ -326,9 +326,14 @@ visibly distinct; `i` opens the resolved equipment detail.
|
|||
scaled to the largest circumference in that observation;
|
||||
- `←`/`→` selection among only metrics that exist in recorded observations,
|
||||
with weight selected by default when available;
|
||||
- an evolution section that reports no data for zero points, reports
|
||||
`1 relevé · tendance indisponible` without a graph for one point, and draws a
|
||||
Unicode time series from real dated values for two or more points;
|
||||
- a reusable Notcurses evolution chart whose X axis represents elapsed time,
|
||||
whose labelled Y axis adds modest visible headroom, and whose 2x4 Braille
|
||||
raster draws a fine piecewise-linear line between stored observations;
|
||||
- distinct accent diamonds for actual measurements, including one centered
|
||||
point with a readable scale for a singleton series; zero points show
|
||||
`Aucune donnée disponible.` without unnecessary axes;
|
||||
- first/last civil-date labels and a non-colliding intermediate graduation
|
||||
when the available width permits it;
|
||||
- detail and correction;
|
||||
- body trend visualization;
|
||||
- normalized multi-metric overlay;
|
||||
|
|
@ -336,8 +341,10 @@ visibly distinct; `i` opens the resolved equipment detail.
|
|||
|
||||
The profile and evolution sections are deliberately separate: kilograms never
|
||||
share a circumference scale, missing measurements are omitted, and the profile
|
||||
bars make no progress or trend claim. The sections stack responsively down to
|
||||
the supported 72x20 terminal.
|
||||
bars make no progress or trend claim. Sparse profiles donate unused rows to the
|
||||
chart. The current content-plane rectangle is recalculated on every render, so
|
||||
the sections resize responsively down to the supported 72x20 terminal without
|
||||
retaining stale coordinates.
|
||||
|
||||
Editing preserves observation identity, timestamp, and optional session link.
|
||||
|
||||
|
|
@ -638,7 +645,82 @@ Proportions can display:
|
|||
All estimates are explicitly labeled as estimates. No result is converted into
|
||||
a medical or diagnostic classification.
|
||||
|
||||
## 18. Training knowledge infrastructure
|
||||
## 18. Statistics center
|
||||
|
||||
`TRAINLOG_TUI_STATISTICS_V1=PASS` organizes the Notcurses statistics route as
|
||||
five keyboard-first views: Corps, Vue globale, Exercices, Zones and MAX. The
|
||||
body view retains recorded weight and circumference history. The other views
|
||||
use read-only aggregates over the existing desktop schema and never persist a
|
||||
derived score or cache.
|
||||
|
||||
The global view switches between the current calendar week, current calendar
|
||||
month and complete history. It reports actual sessions, occurrences, sets, repetitions,
|
||||
available durations, active days/weeks and loaded volume. Loaded volume is
|
||||
strictly `repetitions × actual external load` when both the occurrence and the
|
||||
exercise declare external-load semantics; duration, cardio, assistance and
|
||||
no-load work are excluded. Its common Notcurses chart cycles through bucketed
|
||||
loaded volume, sessions, sets and immediate-feedback counts.
|
||||
|
||||
Exercise statistics are selected by canonical `exercise_id`, include aliases
|
||||
already reconciled by persistence, and omit inapplicable load facts. Their
|
||||
graph cycles through recorded load, valid volume, repetitions, sets and
|
||||
explicit MAX values. The MAX detail displays only persisted explicit tests,
|
||||
including latest, previous, historical best and auditable deltas.
|
||||
|
||||
Zone statistics count distinct actual sessions through persisted
|
||||
`exercise_body_zones` primary/secondary associations. Exercise names are never
|
||||
used to infer a zone. Planning comparisons are limited to targets and actuals
|
||||
owned by the same persisted occurrence. Immediate feedback remains attached
|
||||
to its occurrence; J+1 feedback remains session-global, and free text is not
|
||||
classified or attributed automatically.
|
||||
|
||||
`TRAINLOG_TUI_STATISTICS_REFINEMENT_V1=PASS` keeps these five routes while
|
||||
making their visual semantics explicit. Finite 7-day and 30-day graphs retain
|
||||
the complete selected time domain, including inactive space. Weekly totals use
|
||||
Monday-based local calendar buckets, explicit empty weeks and discrete bars;
|
||||
non-negative counts and volumes use a zero baseline. Continuous observations,
|
||||
exercise progression and explicit MAX histories remain line graphs.
|
||||
|
||||
On sufficiently large planes, the training summary is grouped into Activity,
|
||||
Work, Feedback and Planned/Performed panels; compact planes retain the bounded
|
||||
vertical form. Zone primary and secondary bars share one scale across every
|
||||
visible row and show their raw counts. The MAX catalogue defaults to exercises
|
||||
with an explicit result, shows latest value/date, and uses `a` to reveal all
|
||||
compatible set-based exercises. Prefix search and stable-ID drill-down are
|
||||
unchanged.
|
||||
|
||||
`TRAINLOG_TUI_STATISTICS_PERIOD_BUCKETING_V1=PASS` separates the textual
|
||||
summary window from the comparison depth of its chart. `7j` summarizes the
|
||||
rolling last seven days but compares up to four available consecutive local
|
||||
calendar weeks.
|
||||
|
||||
`TRAINLOG_TUI_STATISTICS_PERIOD_BUCKET_SIZE_FIX=PASS` is the historical
|
||||
checkpoint that first separated comparison sizes. Its duration-based `Tout`
|
||||
adaptation remains active; its finite rolling boundaries are superseded by the
|
||||
calendar contract below. `Tout` uses Monday-based 7-day buckets while its
|
||||
elapsed span fits the bounded graph, then switches the whole graph to calendar
|
||||
months. The decision uses historical duration, so sparse old activity is not
|
||||
silently discarded.
|
||||
|
||||
Explicit zero buckets remain visible between represented boundaries. Each
|
||||
chart uses one bucket size and one zero-based scale, and its title explicitly
|
||||
states 7-day periods, 30-day periods, complete-history weeks, or
|
||||
complete-history months.
|
||||
|
||||
`TRAINLOG_TUI_STATISTICS_CALENDAR_BUCKETS_V1=PASS` supersedes rolling bucket
|
||||
boundaries with calendar alignment. `7j` summarizes the current local calendar
|
||||
week and compares Monday–Sunday weeks. `30j` summarizes the current Gregorian
|
||||
calendar month and compares the current month with up to three preceding
|
||||
months. The current week or month remains partial until its calendar endpoint.
|
||||
|
||||
Historical membership uses the `YYYY-MM-DD` civil date stored in each persisted
|
||||
timestamp. It is never reassigned by converting that timestamp through the
|
||||
current machine timezone. Month keys advance by Gregorian year/month, so
|
||||
28-day February, leap-year February, 30-day months and 31-day months all expose
|
||||
their exact exclusive chart endpoint. French abbreviated month labels and
|
||||
Monday–Sunday ranges make the aggregation visible in the TUI.
|
||||
|
||||
## 19. Training knowledge infrastructure
|
||||
|
||||
The desktop core includes immutable `training_knowledge.h` catalog access and
|
||||
read-only `training_context.h` composition. The context uses real persisted IDs
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
90
tui/include/trainlog/chart.h
Normal file
90
tui/include/trainlog/chart.h
Normal 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
|
||||
186
tui/include/trainlog/statistics.h
Normal file
186
tui/include/trainlog/statistics.h
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
425
tui/src/chart.c
Normal 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
859
tui/src/statistics.c
Normal 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, ¤t_key))
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
if (written < 0 || (size_t)written >= sizeof(sql) ||
|
||||
prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_int64(statement, 1, now) != SQLITE_OK) goto fail;
|
||||
output->buckets_partial = false;
|
||||
output->bucket_kind = TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH;
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
TrainlogStatisticsBucket *bucket;
|
||||
const char *month = (const char *)sqlite3_column_text(statement, 0);
|
||||
int64_t key;
|
||||
if (!month_key_parse(month, &key)) goto fail;
|
||||
if (recent_only && (key < current_key - 3 || key > current_key))
|
||||
continue;
|
||||
if (active_count == TRAINLOG_STATISTICS_BUCKET_MAX) {
|
||||
output->buckets_partial = true; continue;
|
||||
}
|
||||
bucket = &active[active_count];
|
||||
(void)memset(bucket, 0, sizeof(*bucket));
|
||||
active_keys[active_count++] = key;
|
||||
if (!size_column(statement, 1, &bucket->sessions) ||
|
||||
!size_column(statement, 2, &bucket->sets) ||
|
||||
!u64_column(statement, 3, &bucket->repetitions) ||
|
||||
!isfinite(sqlite3_column_double(statement, 4)) ||
|
||||
!size_column(statement, 5, &bucket->immediate_feedback_count)) goto fail;
|
||||
bucket->loaded_volume_kg = sqlite3_column_double(statement, 4);
|
||||
if (key < first_key) first_key = key;
|
||||
}
|
||||
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
output->bucket_count = 0U;
|
||||
if (first_key == INT64_MAX) return TRAINLOG_STATUS_OK;
|
||||
if (recent_only && first_key < current_key - 3) first_key = current_key - 3;
|
||||
if (current_key - first_key + 1 >
|
||||
(int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) {
|
||||
first_key = current_key -
|
||||
((int64_t)TRAINLOG_STATISTICS_BUCKET_MAX - 1);
|
||||
output->buckets_partial = true;
|
||||
}
|
||||
/* INVARIANT: Gregorian month keys are consecutive and non-overlapping.
|
||||
* Filling every key preserves recorded inactivity and naturally handles
|
||||
* February, leap years and year boundaries without day arithmetic. */
|
||||
for (int64_t key = first_key; key <= current_key; ++key) {
|
||||
TrainlogStatisticsBucket *bucket =
|
||||
&output->buckets[output->bucket_count++];
|
||||
char start_date[11];
|
||||
char end_date[11];
|
||||
(void)memset(bucket, 0, sizeof(*bucket));
|
||||
if (!month_key_date(key, start_date) ||
|
||||
!month_key_date(key + 1, end_date) ||
|
||||
!local_date_timestamp(start_date, &bucket->timestamp) ||
|
||||
!local_date_timestamp(end_date, &bucket->end_timestamp) ||
|
||||
!format_month_label(key, bucket->label, sizeof(bucket->label)))
|
||||
goto fail;
|
||||
for (size_t index = 0U; index < active_count; ++index)
|
||||
if (active_keys[index] == key) {
|
||||
int64_t timestamp = bucket->timestamp;
|
||||
int64_t end_timestamp = bucket->end_timestamp;
|
||||
char label[sizeof(bucket->label)];
|
||||
(void)memcpy(label, bucket->label, sizeof(label));
|
||||
*bucket = active[index];
|
||||
bucket->timestamp = timestamp;
|
||||
bucket->end_timestamp = end_timestamp;
|
||||
(void)memcpy(bucket->label, label, sizeof(label));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus load_buckets(TrainlogDatabase *database,
|
||||
TrainlogStatisticsWindow window, int64_t now,
|
||||
TrainlogStatisticsSummary *output)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
if (window == TRAINLOG_STATISTICS_30_DAYS)
|
||||
return load_month_buckets(database, now, true, output);
|
||||
output->bucket_kind = TRAINLOG_STATISTICS_BUCKET_7_DAYS;
|
||||
char sql[4096];
|
||||
int written = snprintf(sql, sizeof(sql),
|
||||
"%s, weeks AS (SELECT date(substr(s.started_at,1,10),printf('-%%d days',(CAST(strftime('%%w',substr(s.started_at,1,10)) AS INTEGER)+6)%%7)) week,"
|
||||
"COUNT(DISTINCT s.id) sessions,COUNT(p.id) sets,COALESCE(SUM(p.reps),0) reps,"
|
||||
"COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0) volume "
|
||||
"FROM actual_sessions s LEFT JOIN actual_occurrences ao ON ao.session_row_id=s.id "
|
||||
"LEFT JOIN exercises e ON e.id=ao.exercise_row_id LEFT JOIN performed_sets p ON p.session_exercise_row_id=ao.id "
|
||||
"WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2 GROUP BY week) "
|
||||
"SELECT week,sessions,sets,reps,volume,"
|
||||
"(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises sf ON sf.id=f.session_exercise_row_id JOIN sessions ss ON ss.id=sf.session_row_id WHERE date(substr(ss.started_at,1,10),printf('-%%d days',(CAST(strftime('%%w',substr(ss.started_at,1,10)) AS INTEGER)+6)%%7))=weeks.week AND unixepoch(ss.started_at) BETWEEN ?1 AND ?2) "
|
||||
"FROM weeks ORDER BY week DESC LIMIT %u;",
|
||||
actual_occurrences_cte(), TRAINLOG_STATISTICS_BUCKET_MAX + 1U);
|
||||
if (written < 0 || (size_t)written >= sizeof(sql) ||
|
||||
prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
bind_window(statement, INT64_MIN, now) != TRAINLOG_STATUS_OK) goto fail;
|
||||
int step;
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
TrainlogStatisticsBucket *bucket;
|
||||
const char *week;
|
||||
time_t seconds;
|
||||
struct tm date;
|
||||
if (output->bucket_count == TRAINLOG_STATISTICS_BUCKET_MAX) {
|
||||
output->buckets_partial = true;
|
||||
break;
|
||||
}
|
||||
bucket = &output->buckets[output->bucket_count++];
|
||||
week = (const char *)sqlite3_column_text(statement, 0);
|
||||
if (!local_date_timestamp(week, &bucket->timestamp) ||
|
||||
!size_column(statement, 1, &bucket->sessions) ||
|
||||
!size_column(statement, 2, &bucket->sets) ||
|
||||
!u64_column(statement, 3, &bucket->repetitions) ||
|
||||
!isfinite(sqlite3_column_double(statement, 4)) ||
|
||||
!size_column(statement, 5, &bucket->immediate_feedback_count)) goto fail;
|
||||
bucket->loaded_volume_kg = sqlite3_column_double(statement, 4);
|
||||
seconds = (time_t)bucket->timestamp;
|
||||
if ((int64_t)seconds != bucket->timestamp ||
|
||||
gmtime_r(&seconds, &date) == NULL ||
|
||||
strftime(bucket->label, sizeof(bucket->label), "%d/%m", &date) == 0U)
|
||||
(void)snprintf(bucket->label, sizeof(bucket->label), "semaine");
|
||||
}
|
||||
if (step != SQLITE_DONE && !output->buckets_partial) goto fail;
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
statement = NULL;
|
||||
/* SQL reads newest-first for bounded history; charts require chronology. */
|
||||
for (size_t left = 0U, right = output->bucket_count;
|
||||
left < right && left < --right; ++left) {
|
||||
TrainlogStatisticsBucket copy = output->buckets[left];
|
||||
output->buckets[left] = output->buckets[right];
|
||||
output->buckets[right] = copy;
|
||||
}
|
||||
/* WHY: active-row count alone misses sparse but very long histories. The
|
||||
* all-history chart chooses one scale from total duration, so it neither
|
||||
* drops an old sparse bucket nor mixes bucket sizes in a single graph. */
|
||||
if (window == TRAINLOG_STATISTICS_ALL && output->bucket_count > 0U) {
|
||||
int64_t current_week;
|
||||
int64_t week_span;
|
||||
if (!current_local_week_timestamp(now, ¤t_week)) goto fail;
|
||||
week_span = (current_week - output->buckets[0].timestamp) /
|
||||
SECONDS_PER_WEEK + 1;
|
||||
if (output->buckets_partial ||
|
||||
week_span > (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX)
|
||||
return load_month_buckets(database, now, false, output);
|
||||
}
|
||||
if (output->bucket_count > 0U) {
|
||||
TrainlogStatisticsBucket filled[TRAINLOG_STATISTICS_BUCKET_MAX];
|
||||
int64_t first_week = output->buckets[0].timestamp;
|
||||
int64_t last_week;
|
||||
int64_t span;
|
||||
size_t filled_count = 0U;
|
||||
size_t source = 0U;
|
||||
if (!current_local_week_timestamp(now, &last_week)) goto fail;
|
||||
if (window == TRAINLOG_STATISTICS_7_DAYS) {
|
||||
int64_t comparison_start = last_week - INT64_C(3) * SECONDS_PER_WEEK;
|
||||
if (first_week < comparison_start) first_week = comparison_start;
|
||||
}
|
||||
span = (last_week - first_week) / SECONDS_PER_WEEK + 1;
|
||||
if (span > (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) {
|
||||
first_week = last_week -
|
||||
((int64_t)TRAINLOG_STATISTICS_BUCKET_MAX - 1) * SECONDS_PER_WEEK;
|
||||
output->buckets_partial = true;
|
||||
}
|
||||
/* INVARIANT: weekly facts include explicit zero weeks. Connecting only
|
||||
* active weeks would visually invent activity inside a chronological gap. */
|
||||
for (int64_t week = first_week; week <= last_week;
|
||||
week += SECONDS_PER_WEEK) {
|
||||
int64_t timestamp = week;
|
||||
time_t seconds = (time_t)timestamp;
|
||||
struct tm date;
|
||||
while (source < output->bucket_count &&
|
||||
output->buckets[source].timestamp < timestamp) ++source;
|
||||
(void)memset(&filled[filled_count], 0, sizeof(filled[filled_count]));
|
||||
filled[filled_count].timestamp = timestamp;
|
||||
filled[filled_count].end_timestamp = timestamp + SECONDS_PER_WEEK;
|
||||
if (source < output->bucket_count &&
|
||||
output->buckets[source].timestamp == timestamp) {
|
||||
int64_t end_timestamp = filled[filled_count].end_timestamp;
|
||||
filled[filled_count] = output->buckets[source];
|
||||
filled[filled_count].end_timestamp = end_timestamp;
|
||||
}
|
||||
if ((int64_t)seconds == timestamp && gmtime_r(&seconds, &date) != NULL) {
|
||||
char start_label[6];
|
||||
char end_label[6];
|
||||
time_t end_seconds = seconds + (time_t)(INT64_C(6) * SECONDS_PER_DAY);
|
||||
struct tm end;
|
||||
if (gmtime_r(&end_seconds, &end) != NULL &&
|
||||
strftime(start_label, sizeof(start_label), "%d/%m", &date) == 5U &&
|
||||
strftime(end_label, sizeof(end_label), "%d/%m", &end) == 5U)
|
||||
(void)snprintf(filled[filled_count].label,
|
||||
sizeof(filled[filled_count].label), "%s-%s",
|
||||
start_label, end_label);
|
||||
}
|
||||
++filled_count;
|
||||
}
|
||||
(void)memcpy(output->buckets, filled,
|
||||
filled_count * sizeof(output->buckets[0]));
|
||||
output->bucket_count = filled_count;
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static TrainlogStatus load_top_feedback(TrainlogDatabase *database,
|
||||
int64_t start, int64_t now, TrainlogStatisticsSummary *output)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const char *sql =
|
||||
"SELECT e.name,COUNT(*) amount FROM exercise_feedback f "
|
||||
"JOIN session_exercises se ON se.id=f.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id "
|
||||
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2 "
|
||||
"GROUP BY e.id ORDER BY amount DESC,e.name COLLATE NOCASE,e.exercise_id LIMIT 1;";
|
||||
int step;
|
||||
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
bind_window(statement, start, now) != TRAINLOG_STATUS_OK) goto fail;
|
||||
step = sqlite3_step(statement);
|
||||
if (step == SQLITE_ROW &&
|
||||
(!copy_text(statement, 0, output->top_feedback_exercise,
|
||||
sizeof(output->top_feedback_exercise)) ||
|
||||
!size_column(statement, 1, &output->top_feedback_count))) goto fail;
|
||||
if (step != SQLITE_ROW && step != SQLITE_DONE) goto fail;
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_statistics_load_summary(TrainlogDatabase *database,
|
||||
TrainlogStatisticsWindow window, int64_t now_utc,
|
||||
TrainlogStatisticsSummary *output)
|
||||
{
|
||||
TrainlogStatus status;
|
||||
int64_t start;
|
||||
if (database == NULL || database->connection == NULL || output == NULL ||
|
||||
window < TRAINLOG_STATISTICS_7_DAYS || window > TRAINLOG_STATISTICS_ALL)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
if (!calendar_window_start(window, now_utc, &start))
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) return status;
|
||||
status = load_summary_row(database, start, now_utc, output);
|
||||
if (status == TRAINLOG_STATUS_OK && output->latest_session_timestamp > 0 &&
|
||||
output->latest_session_timestamp <= now_utc) {
|
||||
uint64_t days = (uint64_t)(now_utc - output->latest_session_timestamp) /
|
||||
(uint64_t)SECONDS_PER_DAY;
|
||||
if (days > (uint64_t)SIZE_MAX) status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
else output->days_since_last_session = (size_t)days;
|
||||
}
|
||||
if (status == TRAINLOG_STATUS_OK)
|
||||
status = load_top_feedback(database, start, now_utc, output);
|
||||
if (status == TRAINLOG_STATUS_OK)
|
||||
status = load_buckets(database, window, now_utc, output);
|
||||
if (trainlog_database_read_snapshot_end(database,
|
||||
status == TRAINLOG_STATUS_OK) != TRAINLOG_STATUS_OK)
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return status;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_statistics_load_zones(TrainlogDatabase *database,
|
||||
int64_t now_utc, TrainlogZoneStatistics *output, size_t capacity,
|
||||
size_t *output_count, bool *output_partial)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const char *sql =
|
||||
"WITH actual_occurrences AS (SELECT se.id,se.session_row_id,se.exercise_row_id FROM session_exercises se "
|
||||
"WHERE EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) "
|
||||
"SELECT z.zone_id,COUNT(DISTINCT s.id),"
|
||||
"COUNT(DISTINCT CASE WHEN z.role='primary' AND unixepoch(s.started_at)>=?1 THEN s.id END),"
|
||||
"COUNT(DISTINCT CASE WHEN z.role='secondary' AND unixepoch(s.started_at)>=?1 THEN s.id END),"
|
||||
"COUNT(DISTINCT CASE WHEN z.role='primary' AND unixepoch(s.started_at)>=?2 THEN s.id END),"
|
||||
"COUNT(DISTINCT CASE WHEN z.role='secondary' AND unixepoch(s.started_at)>=?2 THEN s.id END),"
|
||||
"COUNT(DISTINCT ao.exercise_row_id),"
|
||||
"MAX(CASE WHEN z.role='primary' THEN s.started_at END),"
|
||||
"MAX(CASE WHEN z.role='secondary' THEN s.started_at END) "
|
||||
"FROM exercise_body_zones z JOIN actual_occurrences ao ON ao.exercise_row_id=z.exercise_row_id "
|
||||
"JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at)<=?3 "
|
||||
"GROUP BY z.zone_id ORDER BY z.zone_id;";
|
||||
TrainlogStatus status = TRAINLOG_STATUS_OK;
|
||||
int step;
|
||||
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
||||
output_partial == NULL || (capacity > 0U && output == NULL))
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
*output_count = 0U; *output_partial = false;
|
||||
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_int64(statement, 1, now_utc - INT64_C(7) * SECONDS_PER_DAY) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 2, now_utc - INT64_C(30) * SECONDS_PER_DAY) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 3, now_utc) != SQLITE_OK) goto fail;
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
const unsigned char *zone = sqlite3_column_text(statement, 0);
|
||||
size_t index = *output_count;
|
||||
TrainlogZoneStatistics *value;
|
||||
if (zone == NULL) goto fail;
|
||||
if (index == capacity) { *output_partial = true; continue; }
|
||||
value = &output[index];
|
||||
(void)memset(value, 0, sizeof(*value));
|
||||
if (!copy_text(statement, 0, value->zone_id,
|
||||
sizeof(value->zone_id))) goto fail;
|
||||
{
|
||||
const unsigned char *latest_primary = sqlite3_column_text(statement, 7);
|
||||
const unsigned char *latest_secondary = sqlite3_column_text(statement, 8);
|
||||
if (!size_column(statement, 1, &value->sessions_any) ||
|
||||
!size_column(statement, 2, &value->primary_7) ||
|
||||
!size_column(statement, 3, &value->secondary_7) ||
|
||||
!size_column(statement, 4, &value->primary_30) ||
|
||||
!size_column(statement, 5, &value->secondary_30) ||
|
||||
!size_column(statement, 6, &value->distinct_exercises)) goto fail;
|
||||
if (latest_primary != NULL && !copy_text(statement, 7,
|
||||
value->latest_primary, sizeof(value->latest_primary))) goto fail;
|
||||
if (latest_secondary != NULL && !copy_text(statement, 8,
|
||||
value->latest_secondary, sizeof(value->latest_secondary))) goto fail;
|
||||
}
|
||||
++(*output_count);
|
||||
}
|
||||
if (step != SQLITE_DONE) goto fail;
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return status;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
static bool append_point(TrainlogExerciseStatistics *output,
|
||||
TrainlogExerciseSeriesKind kind, int64_t timestamp, const char *label,
|
||||
double value)
|
||||
{
|
||||
size_t index = output->series_count[kind];
|
||||
TrainlogStatisticPoint *point;
|
||||
if (!isfinite(value)) return false;
|
||||
if (index == TRAINLOG_STATISTICS_SERIES_MAX) {
|
||||
output->series_partial[kind] = true;
|
||||
return true;
|
||||
}
|
||||
point = &output->series[kind][index];
|
||||
point->timestamp = timestamp; point->value = value;
|
||||
if (snprintf(point->timestamp_label, sizeof(point->timestamp_label), "%s",
|
||||
label) < 0) return false;
|
||||
output->series_count[kind] = index + 1U;
|
||||
return true;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_statistics_load_exercise(TrainlogDatabase *database,
|
||||
const char *exercise_id, int64_t now_utc, TrainlogExerciseStatistics *output)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
TrainlogStatus status = TRAINLOG_STATUS_OK;
|
||||
const char *profile_sql = "SELECT name,tracking_mode,recording_mode,COALESCE(load_semantics,'') FROM exercises WHERE exercise_id=?1;";
|
||||
const char *series_sql =
|
||||
"SELECT s.started_at,unixepoch(s.started_at),se.id,"
|
||||
"COUNT(p.id),COALESCE(SUM(p.reps),0),COALESCE(SUM(p.duration_seconds),0)+COALESCE(c.duration_seconds,0),"
|
||||
"MAX(CASE WHEN p.weight_kg IS NOT NULL THEN p.weight_kg END),"
|
||||
"COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND se.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0),"
|
||||
"m.max_weight_kg,se.target_sets,se.target_reps,se.target_weight_kg,"
|
||||
"(SELECT COUNT(*) FROM exercise_feedback f WHERE f.session_exercise_row_id=se.id) "
|
||||
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"LEFT JOIN performed_sets p ON p.session_exercise_row_id=se.id LEFT JOIN continuous_activity c ON c.session_exercise_row_id=se.id "
|
||||
"LEFT JOIN max_results m ON m.session_exercise_row_id=se.id WHERE e.exercise_id=?1 "
|
||||
"AND (p.id IS NOT NULL OR c.id IS NOT NULL OR m.max_weight_kg IS NOT NULL) AND unixepoch(s.started_at)<=?2 "
|
||||
"GROUP BY s.id,se.id ORDER BY unixepoch(s.started_at),s.session_id,se.entry_id;";
|
||||
int step;
|
||||
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
||||
exercise_id[0] == '\0' || output == NULL)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
(void)snprintf(output->exercise_id, sizeof(output->exercise_id), "%s", exercise_id);
|
||||
if (prepare(database->connection, profile_sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK)
|
||||
goto fail;
|
||||
if (sqlite3_step(statement) != SQLITE_ROW ||
|
||||
!copy_text(statement, 0, output->name, sizeof(output->name))) {
|
||||
status = TRAINLOG_STATUS_NOT_FOUND; goto done;
|
||||
}
|
||||
{
|
||||
const char *tracking = (const char *)sqlite3_column_text(statement, 1);
|
||||
const char *recording = (const char *)sqlite3_column_text(statement, 2);
|
||||
if (tracking == NULL || recording == NULL ||
|
||||
!copy_text(statement, 3, output->load_semantics,
|
||||
sizeof(output->load_semantics))) goto fail;
|
||||
output->tracking_mode = strcmp(tracking, "duration") == 0
|
||||
? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS;
|
||||
output->recording_mode = strcmp(recording, "continuous") == 0
|
||||
? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS;
|
||||
}
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto fail; }
|
||||
statement = NULL;
|
||||
if (prepare(database->connection, series_sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 2, now_utc) != SQLITE_OK) goto fail;
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
const unsigned char *at = sqlite3_column_text(statement, 0);
|
||||
int64_t timestamp = sqlite3_column_int64(statement, 1);
|
||||
size_t sets;
|
||||
uint64_t reps, duration;
|
||||
double volume = sqlite3_column_double(statement, 7);
|
||||
if (at == NULL || !size_column(statement, 3, &sets) ||
|
||||
!u64_column(statement, 4, &reps) || !u64_column(statement, 5, &duration) ||
|
||||
!isfinite(volume)) goto fail;
|
||||
++output->occurrences;
|
||||
++output->sessions; /* corrected to distinct below after ordered scan */
|
||||
if (sets > SIZE_MAX - output->sets || reps > UINT64_MAX - output->repetitions ||
|
||||
duration > UINT64_MAX - output->duration_seconds) goto fail;
|
||||
output->sets += sets; output->repetitions += reps;
|
||||
output->duration_seconds += duration; output->loaded_volume_kg += volume;
|
||||
if (output->first_at[0] == '\0')
|
||||
(void)snprintf(output->first_at, sizeof(output->first_at), "%s", at);
|
||||
(void)snprintf(output->last_at, sizeof(output->last_at), "%s", at);
|
||||
if (sqlite3_column_type(statement, 6) != SQLITE_NULL) {
|
||||
double load = sqlite3_column_double(statement, 6);
|
||||
if (!isfinite(load)) goto fail;
|
||||
if (!output->has_highest_load || load > output->highest_load_kg) {
|
||||
output->highest_load_kg = load; output->has_highest_load = true;
|
||||
}
|
||||
if (!append_point(output, TRAINLOG_EXERCISE_SERIES_LOAD,
|
||||
timestamp, (const char *)at, load)) goto fail;
|
||||
}
|
||||
if (sets > 0U && !append_point(output, TRAINLOG_EXERCISE_SERIES_SETS,
|
||||
timestamp, (const char *)at, (double)sets)) goto fail;
|
||||
if (reps > 0U && !append_point(output, TRAINLOG_EXERCISE_SERIES_REPETITIONS,
|
||||
timestamp, (const char *)at, (double)reps)) goto fail;
|
||||
if (volume > 0.0 && !append_point(output, TRAINLOG_EXERCISE_SERIES_VOLUME,
|
||||
timestamp, (const char *)at, volume)) goto fail;
|
||||
if (sqlite3_column_type(statement, 8) != SQLITE_NULL) {
|
||||
double maximum = sqlite3_column_double(statement, 8);
|
||||
if (!isfinite(maximum) || maximum <= 0.0) goto fail;
|
||||
if (output->has_latest_max) {
|
||||
output->previous_max_kg = output->latest_max_kg;
|
||||
output->has_previous_max = true;
|
||||
}
|
||||
output->latest_max_kg = maximum; output->has_latest_max = true;
|
||||
if (output->max_count == 0U || maximum > output->best_max_kg)
|
||||
output->best_max_kg = maximum;
|
||||
++output->max_count;
|
||||
(void)snprintf(output->latest_max_at, sizeof(output->latest_max_at),
|
||||
"%s", at);
|
||||
if (!append_point(output, TRAINLOG_EXERCISE_SERIES_MAX,
|
||||
timestamp, (const char *)at, maximum)) goto fail;
|
||||
}
|
||||
if (sqlite3_column_type(statement, 9) != SQLITE_NULL) {
|
||||
uint64_t target_sets = (uint64_t)sqlite3_column_int64(statement, 9);
|
||||
if (target_sets > UINT64_MAX - output->planned_sets ||
|
||||
(uint64_t)sets > UINT64_MAX - output->actual_sets_for_plans)
|
||||
goto fail;
|
||||
output->planned_sets += target_sets;
|
||||
output->actual_sets_for_plans += (uint64_t)sets;
|
||||
if (sqlite3_column_type(statement, 10) != SQLITE_NULL) {
|
||||
uint64_t target_reps = (uint64_t)sqlite3_column_int64(statement, 10);
|
||||
if (target_reps != 0U && target_sets > UINT64_MAX / target_reps)
|
||||
goto fail;
|
||||
target_reps *= target_sets;
|
||||
if (target_reps > UINT64_MAX - output->planned_repetitions ||
|
||||
reps > UINT64_MAX - output->actual_repetitions_for_plans)
|
||||
goto fail;
|
||||
output->planned_repetitions += target_reps;
|
||||
output->actual_repetitions_for_plans += reps;
|
||||
}
|
||||
if (sqlite3_column_type(statement, 11) != SQLITE_NULL &&
|
||||
sqlite3_column_type(statement, 6) != SQLITE_NULL) {
|
||||
double planned_weight = sqlite3_column_double(statement, 11);
|
||||
double actual_weight = sqlite3_column_double(statement, 6);
|
||||
if (!isfinite(planned_weight) || !isfinite(actual_weight)) goto fail;
|
||||
output->latest_planned_weight_kg = planned_weight;
|
||||
output->latest_actual_weight_kg = actual_weight;
|
||||
output->has_latest_planned_actual_weight = true;
|
||||
}
|
||||
}
|
||||
{
|
||||
size_t feedback;
|
||||
if (!size_column(statement, 12, &feedback) ||
|
||||
feedback > SIZE_MAX - output->immediate_feedback_count) goto fail;
|
||||
output->immediate_feedback_count += feedback;
|
||||
}
|
||||
}
|
||||
if (step != SQLITE_DONE) goto fail;
|
||||
/* Occurrences of one exercise can repeat inside a session. This aggregate
|
||||
* query is chronological but not session-ID preserving, so obtain the exact
|
||||
* distinct count in one constant supplementary query. */
|
||||
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto fail; }
|
||||
statement = NULL;
|
||||
if (prepare(database->connection,
|
||||
"SELECT COUNT(DISTINCT CASE WHEN (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END),"
|
||||
"COUNT(DISTINCT CASE WHEN unixepoch(s.started_at)>=?2-604800 AND (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END),"
|
||||
"COUNT(DISTINCT CASE WHEN unixepoch(s.started_at)>=?2-2592000 AND (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END),"
|
||||
"(SELECT p2.reps FROM performed_sets p2 JOIN session_exercises se2 ON se2.id=p2.session_exercise_row_id JOIN sessions s2 ON s2.id=se2.session_row_id JOIN exercises e2 ON e2.id=se2.exercise_row_id WHERE e2.exercise_id=?1 AND p2.reps IS NOT NULL AND p2.weight_kg IS NOT NULL AND unixepoch(s2.started_at)<=?2 ORDER BY p2.reps DESC,p2.weight_kg DESC,s2.started_at DESC LIMIT 1),"
|
||||
"(SELECT p2.weight_kg FROM performed_sets p2 JOIN session_exercises se2 ON se2.id=p2.session_exercise_row_id JOIN sessions s2 ON s2.id=se2.session_row_id JOIN exercises e2 ON e2.id=se2.exercise_row_id WHERE e2.exercise_id=?1 AND p2.reps IS NOT NULL AND p2.weight_kg IS NOT NULL AND unixepoch(s2.started_at)<=?2 ORDER BY p2.reps DESC,p2.weight_kg DESC,s2.started_at DESC LIMIT 1) "
|
||||
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE e.exercise_id=?1 AND unixepoch(s.started_at)<=?2;",
|
||||
&statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 2, now_utc) != SQLITE_OK ||
|
||||
sqlite3_step(statement) != SQLITE_ROW ||
|
||||
!size_column(statement, 0, &output->sessions) ||
|
||||
!size_column(statement, 1, &output->frequency_7) ||
|
||||
!size_column(statement, 2, &output->frequency_30)) goto fail;
|
||||
if (sqlite3_column_type(statement, 3) != SQLITE_NULL) {
|
||||
output->highest_repetitions = sqlite3_column_int(statement, 3);
|
||||
output->has_repetitions_at_load = sqlite3_column_type(statement, 4) != SQLITE_NULL;
|
||||
if (output->has_repetitions_at_load)
|
||||
output->highest_repetitions_load_kg = sqlite3_column_double(statement, 4);
|
||||
}
|
||||
done:
|
||||
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK)
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return status;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_statistics_list_maxima(TrainlogDatabase *database,
|
||||
TrainlogMaxListItem *output, size_t capacity, size_t *output_count,
|
||||
bool *output_partial)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const char *sql =
|
||||
"SELECT exercise_id,max_weight_kg,started_at FROM ("
|
||||
"SELECT e.exercise_id,m.max_weight_kg,s.started_at,"
|
||||
"ROW_NUMBER() OVER (PARTITION BY e.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC) rank "
|
||||
"FROM max_results m JOIN session_exercises se ON se.id=m.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id) "
|
||||
"WHERE rank=1 ORDER BY started_at DESC,exercise_id LIMIT ?1;";
|
||||
int step;
|
||||
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
||||
output_partial == NULL || (capacity > 0U && output == NULL) ||
|
||||
capacity == SIZE_MAX) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
*output_count = 0U; *output_partial = false;
|
||||
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK)
|
||||
goto fail;
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
TrainlogMaxListItem *item;
|
||||
if (*output_count == capacity) { *output_partial = true; continue; }
|
||||
item = &output[*output_count];
|
||||
(void)memset(item, 0, sizeof(*item));
|
||||
if (!copy_text(statement, 0, item->exercise_id, sizeof(item->exercise_id)) ||
|
||||
!isfinite(sqlite3_column_double(statement, 1)) ||
|
||||
!copy_text(statement, 2, item->latest_at, sizeof(item->latest_at))) goto fail;
|
||||
item->latest_max_kg = sqlite3_column_double(statement, 1);
|
||||
++(*output_count);
|
||||
}
|
||||
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_statistics_list_exercises(TrainlogDatabase *database,
|
||||
TrainlogExerciseListStatistic *output, size_t capacity,
|
||||
size_t *output_count, bool *output_partial)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
char sql[2048];
|
||||
int written = snprintf(sql, sizeof(sql),
|
||||
"%s SELECT e.exercise_id,COUNT(*),MAX(s.started_at) FROM actual_occurrences ao "
|
||||
"JOIN sessions s ON s.id=ao.session_row_id JOIN exercises e ON e.id=ao.exercise_row_id "
|
||||
"GROUP BY e.id ORDER BY MAX(unixepoch(s.started_at)) DESC,e.exercise_id LIMIT ?1;",
|
||||
actual_occurrences_cte());
|
||||
int step;
|
||||
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
||||
output_partial == NULL || (capacity > 0U && output == NULL) ||
|
||||
capacity == SIZE_MAX || written < 0 || (size_t)written >= sizeof(sql))
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
*output_count = 0U; *output_partial = false;
|
||||
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
||||
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK)
|
||||
goto fail;
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
TrainlogExerciseListStatistic *item;
|
||||
if (*output_count == capacity) { *output_partial = true; continue; }
|
||||
item = &output[*output_count];
|
||||
(void)memset(item, 0, sizeof(*item));
|
||||
if (!copy_text(statement, 0, item->exercise_id, sizeof(item->exercise_id)) ||
|
||||
!size_column(statement, 1, &item->occurrences) ||
|
||||
!copy_text(statement, 2, item->latest_at, sizeof(item->latest_at))) goto fail;
|
||||
++(*output_count);
|
||||
}
|
||||
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
fail:
|
||||
if (statement != NULL) (void)sqlite3_finalize(statement);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
818
tui/src/tui.c
818
tui/src/tui.c
File diff suppressed because it is too large
Load diff
181
tui/tests/test_chart.c
Normal file
181
tui/tests/test_chart.c
Normal 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
359
tui/tests/test_statistics.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue