refactor(core): extract dashboard read model

This commit is contained in:
fy59 2026-09-16 08:26:48 +02:00
parent 3e06039890
commit 456713586b
13 changed files with 1487 additions and 1328 deletions

View file

@ -119,14 +119,16 @@ layout is compact from 72x20, has a sidebar from 100x26, and expands it from
and run-scoped terminal planes keep Notcurses infrastructure separate from
product semantics.
The current Dashboard implementation retains one known layering violation:
`tui.c` uses SQLite and `database_internal.h` directly to build Dashboard facts
and calculations. `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` now captures
that behavior, including its bounds and legacy-data flags. This remains a
blocking debt for the Web Dashboard: before any Dashboard HTTP endpoint exists,
the query and calculations must move incrementally into a typed Core read model
and the TUI must consume that read model. This requirement does not authorize a
global `tui.c` refactor.
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` resolves the former Dashboard
layering violation. `tui/src/dashboard.c` alone owns its SQLite fact projection,
normalization, bounds, ordering, rolling windows, aggregates and legacy-data
flags. Its public `trainlog/dashboard.h` boundary contains no SQLite, Notcurses
or private TUI type. The TUI calls that service with an explicit reference Unix
second and presents the caller-owned bounded snapshot; it neither includes
`database_internal.h` for the Dashboard nor reproduces its calculations.
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` was completed in the same tranche
because live TUI adoption is the parity proof. This was an incremental
extraction, not a global `tui.c` refactor.
### Local Web application

View file

@ -37,6 +37,8 @@ parallel implementation of its rules.
| Application shell | `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` |
| Statistics | `STATS_V1=IMPLEMENTED` |
| Dashboard characterization | `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` |
| Dashboard Core read model | `WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` |
| TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
@ -125,12 +127,10 @@ The desktop is a strict C17 application using Notcurses. It currently provides:
including selected-language formatting without changing canonical data.
Rendering is not permitted to own SQL or business rules. Statistics are
read-only projections; they are not persisted as facts. The current TUI
Dashboard still has a known exception: its direct SQLite/
`database_internal.h` fact projection is now frozen by characterization tests.
It must next be extracted into a typed Core read model and consumed by the TUI
before any Web Dashboard endpoint is created. This is an incremental
extraction, not authorization for a global `tui.c` refactor.
read-only projections; they are not persisted as facts. The former Dashboard
exception is resolved: its typed Core read model owns the SQLite projection and
all characterized calculations, while the TUI supplies query state and renders
the result. No Dashboard HTTP endpoint exists yet.
## Local Web
@ -165,6 +165,10 @@ import-contract validator. The source-derived TUI
`TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and
diff safety checks pass in the final audit evidence.
The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and
**62/62 ASan/UBSan Meson tests**, including the standalone public-header and
Core characterization targets, plus both canonical JSON validators.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and
@ -177,9 +181,9 @@ smoke test are not automated. The latter is why
## Active limitations
- `TRAINLOG_WEB_V1` is contract-only; Dashboard characterization is frozen and
the next implementation step is Core read-model extraction, not React or
HTTP infrastructure.
- `TRAINLOG_WEB_V1` remains contract-only; Dashboard characterization, Core
extraction and TUI adoption are frozen. The next implementation cursor is
CLI/local HTTP infrastructure, not React.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.

View file

@ -30,14 +30,17 @@ architecture is canonical, but no Web code exists yet.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_CORE_READ_MODEL_V1
CURRENT_OPERATIONAL_CURSOR=WEB_CLI_HTTP_INFRASTRUCTURE_V1
```
`WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN`. Move the now-characterized
direct SQLite and `database_internal.h` projection into a typed Core read
model without changing its results. Then adapt the TUI to consume that read
model. This incremental sequence is a blocking prerequisite for a Web
Dashboard endpoint and does not authorize a global `tui.c` refactor.
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` and
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN`. The characterized Dashboard SQL,
facts, limits and calculations now live in the typed Core service
`trainlog_dashboard_load()`; the TUI supplies the selected period and current
time, then presents the returned snapshot. Adoption was necessary to prove
parity and was therefore completed in the extraction tranche rather than left
as an empty roadmap step. The next authorized slice is local CLI/HTTP
infrastructure; it does not yet authorize React or Dashboard Web metrics.
## TRAINLOG_WEB_V1 implementation gate
@ -45,8 +48,8 @@ Implementation order is fixed as:
```text
WEB_DASHBOARD_CHARACTERIZATION_V1
-> WEB_DASHBOARD_CORE_READ_MODEL_V1
-> WEB_TUI_READ_MODEL_ADOPTION_V1
-> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN]
-> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN]
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1
-> WEB_FRONTEND_SHELL_V1
-> WEB_DASHBOARD_V1

View file

@ -188,8 +188,9 @@ tests (198 passed, 4 skipped, 0 failed), with both JSON validators passing.
Automated coverage does not replace a real device/manual visual language-switch
smoke; that remains explicit manual validation.
`WEB_DASHBOARD_CHARACTERIZATION_V1` exercises the production Dashboard
projection through a real in-memory SQLite database. Its fixed test clock
`WEB_DASHBOARD_CHARACTERIZATION_V1` now exercises the production Core service
directly through a real in-memory SQLite database, without including `tui.c` or
linking Notcurses. Its explicit fixed query instant
covers an empty training history, exact rolling-window inclusion immediately
before/at/after the 30-day boundary, valid actual history without `ended_at`,
and plan-only exclusion. It covers repeated occurrences, heterogeneous REPS
@ -203,6 +204,9 @@ sessions and 4096 Dashboard facts, including the independently truncated totals
and `partial` flag. Existing regressions continue to cover malformed legacy
timestamps, body-metric selection, responsive rendering, all-history buckets,
strictly earlier exact-dose improvement, and explicit-MAX separation.
The standalone `dashboard_header` target also proves that
`trainlog/dashboard.h` compiles independently as C17. TUI workflow regressions
separately verify service adoption and snapshot presentation.
Notable regression coverage:

View file

@ -449,6 +449,17 @@ no set-position tie-break; the preceding fact order is session, entry, kind,
then set position. The Core extraction must initially preserve these observed
semantics or explicitly return to contract review before changing them.
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` preserves those semantics in
the Core API `trainlog_dashboard_load()`. Its query makes the reference Unix
second explicit, so rolling boundaries are deterministic without a test-only
production clock. The snapshot is caller-owned and retains the characterized
128-session, 4096-fact, 128-performance-point, 256-body-point and 12-bucket
bounds plus conservative `partial` and `invalid_data` flags. The TUI owns only
period selection, the normal `time(NULL)` capture, localization, formatting,
layout, rendering and interaction. Consequently
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` is complete as part of the same
extraction tranche.
The snapshot reads at most 128 observable sessions, 4096 occurrence facts, 128
selected performance points, and 256 points for each body metric. Reaching a
bound sets the conservative `partial` flag. At exactly 128 sessions the view is

View file

@ -0,0 +1,104 @@
/*
* Trainlog Dashboard Core read model.
*
* This public boundary deliberately exposes neither SQLite nor TUI types.
*/
#ifndef TRAINLOG_DASHBOARD_H
#define TRAINLOG_DASHBOARD_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "trainlog/database.h"
#define TRAINLOG_DASHBOARD_SESSION_CAPACITY 128U
#define TRAINLOG_DASHBOARD_FACT_CAPACITY 4096U
#define TRAINLOG_DASHBOARD_PERFORMANCE_CAPACITY 128U
#define TRAINLOG_DASHBOARD_BODY_CAPACITY 256U
#define TRAINLOG_DASHBOARD_BUCKET_CAPACITY 12U
#define TRAINLOG_DASHBOARD_ZONE_CAPACITY 17U
#define TRAINLOG_DASHBOARD_BODY_METRIC_COUNT 14U
typedef enum TrainlogDashboardPeriod {
TRAINLOG_DASHBOARD_30_DAYS = 0,
TRAINLOG_DASHBOARD_7_DAYS,
TRAINLOG_DASHBOARD_90_DAYS,
TRAINLOG_DASHBOARD_YEAR,
TRAINLOG_DASHBOARD_ALL
} TrainlogDashboardPeriod;
typedef struct TrainlogDashboardQuery {
TrainlogDashboardPeriod period;
/* CONTRACT: explicit UTC Unix second used by every rolling window and by
* the final presentation bucket. This makes one query deterministic. */
int64_t reference_unix_second;
} TrainlogDashboardQuery;
typedef struct TrainlogDashboardBucket {
int64_t start_day;
int64_t span_days;
size_t sessions;
size_t maxima;
size_t working_improvements;
size_t max_improvements;
} TrainlogDashboardBucket;
typedef struct TrainlogDashboardZoneBucket {
/* Empty means the explicit unclassified bucket. */
char zone_id[TRAINLOG_ZONE_ID_MAX + 1U];
size_t count;
} TrainlogDashboardZoneBucket;
typedef struct TrainlogDashboardSnapshot {
bool error;
bool partial;
bool invalid_data;
bool has_performance;
TrainlogExercise exercise;
TrainlogLoadMode performance_load_mode;
TrainlogTrackingMode performance_tracking_mode;
int performance_dose;
char performance_equipment_id[TRAINLOG_ID_MAX + 1U];
char performance_equipment_label[TRAINLOG_NAME_MAX + 1U];
TrainlogExercisePerformancePoint
performance[TRAINLOG_DASHBOARD_PERFORMANCE_CAPACITY];
size_t performance_count;
bool has_explicit_max;
TrainlogExercise max_exercise;
double max_weight_kg;
char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
bool has_body;
size_t body_metric;
TrainlogBodyMetricPoint body[TRAINLOG_DASHBOARD_BODY_CAPACITY];
size_t body_count;
TrainlogDashboardBucket weeks[TRAINLOG_DASHBOARD_BUCKET_CAPACITY];
size_t week_count;
size_t completed_session_count;
size_t performed_set_count;
size_t distinct_exercise_count;
size_t explicit_max_count;
/* CONTRACT: current canonical catalogue, primary zone only; aliases and
* secondary zones never contribute. Missing primary zones are explicit. */
TrainlogDashboardZoneBucket zones[TRAINLOG_DASHBOARD_ZONE_CAPACITY];
size_t zone_count;
TrainlogDashboardPeriod period;
} TrainlogDashboardSnapshot;
/*
* WHY: all interfaces must consume one canonical Dashboard projection.
* CONTRACT: database/query/snapshot are borrowed and required. The function
* initializes the complete caller-owned snapshot and retains no pointer or
* SQLite resource. Invalid periods return INVALID_ARGUMENT. A non-OK result
* sets snapshot->error; partial and invalid_data remain independent quality
* flags preserving the frozen legacy projection.
* INVARIANT: capacities and ordering are part of
* WEB_DASHBOARD_CHARACTERIZATION_V1 and must not change implicitly.
*/
TrainlogStatus trainlog_dashboard_load(
TrainlogDatabase *database,
const TrainlogDashboardQuery *query,
TrainlogDashboardSnapshot *snapshot
);
#endif

View file

@ -90,6 +90,7 @@ trainlog_core_sources = files(
'src/body_analytics.c',
'src/catalog.c',
'src/database.c',
'src/dashboard.c',
'src/duration.c',
'src/id.c',
'src/timeutil.c',
@ -200,6 +201,22 @@ test_body_zones = executable(
)
test('body_zones', test_body_zones)
test_dashboard = executable(
'test_dashboard',
'tests/test_dashboard.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('dashboard', test_dashboard)
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',
include_directories: trainlog_include,
c_args: strict_c_args,
)
test('dashboard_header', test_dashboard_header)
test_training_knowledge = executable(
'test_training_knowledge',
'tests/test_training_knowledge.c',

717
tui/src/dashboard.c Normal file
View file

@ -0,0 +1,717 @@
/**
* @file dashboard.c
* @brief Canonical bounded Dashboard read model.
*/
#include "trainlog/dashboard.h"
#include <limits.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include "trainlog/body_zone_catalog.h"
#include "timestamp.h"
#include "database_internal.h"
#define DASHBOARD_BODY_ZONE_RELATION_CAPACITY 16U
typedef enum TrainlogDashboardFactKind {
TRAINLOG_DASHBOARD_WORK,
TRAINLOG_DASHBOARD_MAX,
TRAINLOG_DASHBOARD_CONTINUOUS
} TrainlogDashboardFactKind;
typedef struct TrainlogDashboardFact {
TrainlogDashboardFactKind kind;
TrainlogTimestampKey time;
char session_id[TRAINLOG_ID_MAX + 1U];
char entry_id[TRAINLOG_ID_MAX + 1U];
char exercise_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char equipment_id[TRAINLOG_ID_MAX + 1U];
TrainlogTrackingMode tracking_mode;
TrainlogLoadMode load_mode;
size_t set_position;
int dose;
bool has_weight;
double weight_kg;
} TrainlogDashboardFact;
static const TrainlogBodyMetric DASHBOARD_BODY_METRICS[] = {
TRAINLOG_BODY_METRIC_WEIGHT, TRAINLOG_BODY_METRIC_NECK,
TRAINLOG_BODY_METRIC_SHOULDERS, TRAINLOG_BODY_METRIC_CHEST,
TRAINLOG_BODY_METRIC_WAIST, TRAINLOG_BODY_METRIC_HIPS,
TRAINLOG_BODY_METRIC_LEFT_ARM, TRAINLOG_BODY_METRIC_RIGHT_ARM,
TRAINLOG_BODY_METRIC_LEFT_FOREARM, TRAINLOG_BODY_METRIC_RIGHT_FOREARM,
TRAINLOG_BODY_METRIC_LEFT_THIGH, TRAINLOG_BODY_METRIC_RIGHT_THIGH,
TRAINLOG_BODY_METRIC_LEFT_CALF, TRAINLOG_BODY_METRIC_RIGHT_CALF
};
_Static_assert(sizeof(DASHBOARD_BODY_METRICS) /
sizeof(DASHBOARD_BODY_METRICS[0]) ==
TRAINLOG_DASHBOARD_BODY_METRIC_COUNT,
"Dashboard body metric contract must remain complete");
static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column,
char *output, size_t capacity)
{
const unsigned char *value;
int bytes;
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false;
value = sqlite3_column_text(statement, column);
bytes = sqlite3_column_bytes(statement, column);
if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false;
(void)memcpy(output, value, (size_t)bytes);
output[bytes] = '\0';
return true;
}
/* WHY: the global dashboard compares occurrence-owned facts, while the public
* performance reader deliberately collapses each session to one representative.
* CONTRACT: this private projection emits every performed set, every explicit
* MAX exactly once, and every continuous occurrence exactly once. INVARIANT:
* malformed legacy timestamps are omitted before sorting and reported through
* invalid_data; they can never reach a comparator with uninitialized keys. */
static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
TrainlogDashboardFact *output, size_t capacity, size_t *output_count,
bool *truncated, bool *invalid_data)
{
static const char *const SQL =
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position,"
"CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
"ps.weight_kg FROM performed_sets ps "
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0,"
"mr.max_weight_kg FROM max_results mr "
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,"
"ca.duration_seconds,NULL FROM continuous_activity ca "
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int rc;
if (output_count != NULL) *output_count = 0U;
if (truncated != NULL) *truncated = false;
if (invalid_data != NULL) *invalid_data = false;
if (database == NULL || database->connection == NULL || output_count == NULL ||
truncated == NULL || invalid_data == NULL ||
(capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogDashboardFact fact;
const unsigned char *tracking;
const unsigned char *load;
int kind;
(void)memset(&fact, 0, sizeof(fact));
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER ||
!dashboard_copy_sql_text(statement, 1, fact.started_at,
sizeof(fact.started_at)) ||
!dashboard_copy_sql_text(statement, 2, fact.session_id,
sizeof(fact.session_id)) ||
!dashboard_copy_sql_text(statement, 3, fact.entry_id,
sizeof(fact.entry_id)) ||
!dashboard_copy_sql_text(statement, 4, fact.exercise_id,
sizeof(fact.exercise_id)) ||
!dashboard_copy_sql_text(statement, 5, fact.equipment_id,
sizeof(fact.equipment_id)) ||
sqlite3_column_type(statement, 6) != SQLITE_TEXT ||
sqlite3_column_type(statement, 7) != SQLITE_TEXT ||
!trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at),
&fact.time)) {
*invalid_data = true;
continue;
}
kind = sqlite3_column_int(statement, 0);
if (kind < 0 || kind > 2) {
*invalid_data = true;
continue;
}
fact.kind = (TrainlogDashboardFactKind)kind;
tracking = sqlite3_column_text(statement, 6);
load = sqlite3_column_text(statement, 7);
if (strcmp((const char *)tracking, "reps") == 0)
fact.tracking_mode = TRAINLOG_TRACKING_REPS;
else if (strcmp((const char *)tracking, "duration") == 0)
fact.tracking_mode = TRAINLOG_TRACKING_DURATION;
else { *invalid_data = true; continue; }
if (strcmp((const char *)load, "none") == 0)
fact.load_mode = TRAINLOG_LOAD_NONE;
else if (strcmp((const char *)load, "external") == 0)
fact.load_mode = TRAINLOG_LOAD_EXTERNAL;
else if (strcmp((const char *)load, "assistance") == 0)
fact.load_mode = TRAINLOG_LOAD_ASSISTANCE;
else { *invalid_data = true; continue; }
if (fact.kind == TRAINLOG_DASHBOARD_WORK) {
sqlite3_int64 position;
if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER ||
sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
*invalid_data = true; continue;
}
position = sqlite3_column_int64(statement, 8);
if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) {
*invalid_data = true; continue;
}
fact.set_position = (size_t)position;
fact.dose = sqlite3_column_int(statement, 9);
} else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) {
if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
*invalid_data = true; continue;
}
fact.dose = sqlite3_column_int(statement, 9);
}
if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) {
int type = sqlite3_column_type(statement, 10);
if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) {
fact.has_weight = false;
} else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) ||
!isfinite(sqlite3_column_double(statement, 10))) {
*invalid_data = true; continue;
} else {
fact.has_weight = true;
fact.weight_kg = sqlite3_column_double(statement, 10);
}
if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) {
*invalid_data = true; continue;
}
}
if (count == capacity) { *truncated = true; continue; }
output[count++] = fact;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
*output_count = count;
return TRAINLOG_STATUS_OK;
}
static int dashboard_fact_time_compare(const void *left, const void *right)
{
const TrainlogDashboardFact *a = left;
const TrainlogDashboardFact *b = right;
int result = trainlog_timestamp_compare(&a->time, &b->time);
if (result != 0) return result;
result = strcmp(a->session_id, b->session_id);
if (result != 0) return result;
result = strcmp(a->entry_id, b->entry_id);
if (result != 0) return result;
if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1;
return a->set_position == b->set_position ? 0 :
(a->set_position < b->set_position ? -1 : 1);
}
static int dashboard_performance_time_compare(const void *left, const void *right)
{
const TrainlogExercisePerformancePoint *a = left;
const TrainlogExercisePerformancePoint *b = right;
TrainlogTimestampKey a_key;
TrainlogTimestampKey b_key;
(void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key);
(void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key);
{
int result = trainlog_timestamp_compare(&a_key, &b_key);
return result != 0 ? result : strcmp(a->session_id, b->session_id);
}
}
static int dashboard_body_time_compare(const void *left, const void *right)
{
const TrainlogBodyMetricPoint *a = left;
const TrainlogBodyMetricPoint *b = right;
TrainlogTimestampKey a_key;
TrainlogTimestampKey b_key;
(void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key);
(void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key);
return trainlog_timestamp_compare(&a_key, &b_key);
}
static int64_t dashboard_floor_div(int64_t value, int64_t divisor)
{
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
}
static int64_t dashboard_period_days(TrainlogDashboardPeriod period)
{
switch (period) {
case TRAINLOG_DASHBOARD_7_DAYS: return 7;
case TRAINLOG_DASHBOARD_30_DAYS: return 30;
case TRAINLOG_DASHBOARD_90_DAYS: return 90;
case TRAINLOG_DASHBOARD_YEAR: return 365;
case TRAINLOG_DASHBOARD_ALL: return 0;
}
return 30;
}
static bool dashboard_in_period(const TrainlogTimestampKey *key,
TrainlogDashboardPeriod period, int64_t reference_unix_second)
{
const int64_t days = dashboard_period_days(period);
/* Unix time and Trainlog's proleptic day zero differ by 719162 days. */
const int64_t now = reference_unix_second +
INT64_C(719162) * INT64_C(86400);
return key->utc_second <= now &&
(days == 0 || key->utc_second >= now - days * INT64_C(86400));
}
/* WHY: the selected period must change both dashboard charts, while the
* canonical rolling membership test above remains the sole inclusion rule.
* CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day
* and roughly monthly (31-day) presentation buckets; Tout uses at most twelve
* equal day spans covering represented history. INVARIANT: every included
* local date is clamped into exactly one leading-to-current bucket. */
static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard,
int64_t current_day, int64_t earliest_day)
{
size_t count;
int64_t span;
size_t index;
switch (dashboard->period) {
case TRAINLOG_DASHBOARD_7_DAYS: count = 7U; span = 1; break;
case TRAINLOG_DASHBOARD_30_DAYS: count = 6U; span = 5; break;
case TRAINLOG_DASHBOARD_90_DAYS: count = 6U; span = 15; break;
case TRAINLOG_DASHBOARD_YEAR: count = 12U; span = 31; break;
case TRAINLOG_DASHBOARD_ALL: {
int64_t represented = current_day >= earliest_day
? current_day - earliest_day + 1 : 1;
count = represented < (int64_t)TRAINLOG_DASHBOARD_BUCKET_CAPACITY
? (size_t)represented : TRAINLOG_DASHBOARD_BUCKET_CAPACITY;
if (count == 0U) count = 1U;
span = (represented + (int64_t)count - 1) / (int64_t)count;
break;
}
}
dashboard->week_count = count;
for (index = 0U; index < count; ++index) {
int64_t distance = (int64_t)(count - 1U - index) * span;
dashboard->weeks[index].start_day = current_day - distance - span + 1;
dashboard->weeks[index].span_days = span;
}
}
static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard,
int64_t local_day)
{
int64_t first;
int64_t span;
size_t slot;
if (dashboard->week_count == 0U) return 0U;
first = dashboard->weeks[0].start_day;
span = dashboard->weeks[0].span_days;
if (local_day <= first) return 0U;
slot = (size_t)((local_day - first) / span);
return slot < dashboard->week_count ? slot : dashboard->week_count - 1U;
}
static bool dashboard_fact_external_context_qualifies(TrainlogDatabase *database,
const TrainlogDashboardFact *fact)
{
TrainlogResolvedEquipment equipment;
if (fact->equipment_id[0] == '\0' ||
trainlog_database_resolve_equipment(database, fact->equipment_id,
&equipment) != TRAINLOG_STATUS_OK) return false;
return strcmp(equipment.load_semantics, "external") == 0;
}
static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises,
size_t count, const char *exercise_id)
{
size_t index;
for (index = 0U; index < count; ++index)
if (strcmp(exercises[index].exercise_id, exercise_id) == 0)
return &exercises[index];
return NULL;
}
static int dashboard_zone_bucket_compare(const void *left, const void *right)
{
const TrainlogDashboardZoneBucket *a = left;
const TrainlogDashboardZoneBucket *b = right;
const TrainlogBodyZone *a_zone;
const TrainlogBodyZone *b_zone;
const char *a_label;
const char *b_label;
if (a->count != b->count) return a->count > b->count ? -1 : 1;
a_zone = trainlog_body_zone_catalog_lookup(a->zone_id);
b_zone = trainlog_body_zone_catalog_lookup(b->zone_id);
a_label = a_zone == NULL ? "Non classés" : a_zone->display_name;
b_label = b_zone == NULL ? "Non classés" : b_zone->display_name;
return strcmp(a_label, b_label);
}
/* WHY: a body-zone dashboard must describe the current catalogue rather than
* infer classifications from history or commercial names. CONTRACT: one
* canonical exercise is counted once, using only its persisted primary body
* zone. INVARIANT: missing primary relations remain visible as "Non classés";
* secondary relations and aliases can neither create nor duplicate a bucket. */
static bool dashboard_load_zone_distribution(TrainlogDatabase *database, TrainlogDashboardSnapshot *dashboard,
const TrainlogExercise *exercises, size_t exercise_count)
{
size_t exercise_index;
for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) {
TrainlogExerciseBodyZone
relations[DASHBOARD_BODY_ZONE_RELATION_CAPACITY];
const char *zone_id = "";
size_t relation_count = 0U;
size_t relation_index;
size_t bucket;
if (trainlog_database_list_exercise_body_zones(database,
exercises[exercise_index].exercise_id, relations,
DASHBOARD_BODY_ZONE_RELATION_CAPACITY,
&relation_count) != TRAINLOG_STATUS_OK) return false;
for (relation_index = 0U; relation_index < relation_count; ++relation_index) {
if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
relations[relation_index].zone_id);
if (zone != NULL && !zone->is_group) zone_id = zone->zone_id;
break;
}
}
for (bucket = 0U; bucket < dashboard->zone_count; ++bucket)
if (strcmp(dashboard->zones[bucket].zone_id, zone_id) == 0) break;
if (bucket == dashboard->zone_count) {
if (bucket == TRAINLOG_DASHBOARD_ZONE_CAPACITY) return false;
(void)memcpy(dashboard->zones[bucket].zone_id, zone_id,
strlen(zone_id) + 1U);
++dashboard->zone_count;
}
++dashboard->zones[bucket].count;
}
qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]),
dashboard_zone_bucket_compare);
return true;
}
/* WHY: the landing page must remain useful without rendering-time SQL.
* CONTRACT: this bounded controller snapshot contains only actual persisted
* facts. One working-performance series has an exact exercise/equipment/load
* identity; explicit MAX is separate, and assistance/planned targets never
* enter it. All chronology is decided by Trainlog's timestamp parser, not
* SQLite text ordering. */
TrainlogStatus trainlog_dashboard_load(
TrainlogDatabase *database, const TrainlogDashboardQuery *query,
TrainlogDashboardSnapshot *dashboard)
{
const int64_t epoch_offset = INT64_C(719162) * INT64_C(86400);
TrainlogDashboardPeriod period;
TrainlogExercise *exercises = NULL;
TrainlogDashboardFact *facts = NULL;
TrainlogBodyMetricPoint body[TRAINLOG_DASHBOARD_BODY_CAPACITY];
TrainlogSessionSummary sessions[TRAINLOG_DASHBOARD_SESSION_CAPACITY];
TrainlogTimestampKey newest_performance;
char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = "";
size_t exercise_count = 0U;
size_t fact_count = 0U;
size_t session_count = 0U;
size_t exercise_index;
int64_t current_day;
int64_t earliest_day = INT64_MAX;
bool has_newest_performance = false;
bool facts_truncated = false;
bool facts_invalid = false;
if (dashboard == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT;
(void)memset(dashboard, 0, sizeof(*dashboard));
if (database == NULL || query == NULL ||
query->period < TRAINLOG_DASHBOARD_30_DAYS ||
query->period > TRAINLOG_DASHBOARD_ALL ||
query->reference_unix_second > INT64_MAX - epoch_offset) {
dashboard->error = true;
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
period = query->period;
dashboard->period = period;
facts = calloc(TRAINLOG_DASHBOARD_FACT_CAPACITY, sizeof(*facts));
if (facts == NULL) {
dashboard->error = true;
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
if (trainlog_database_read_snapshot_begin(database) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
free(facts);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (trainlog_database_list_sessions(database, sessions, TRAINLOG_DASHBOARD_SESSION_CAPACITY,
&session_count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
dashboard->partial = session_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY;
/* CONTRACT: frequency is one count per actual session; a MAX marker is an
* attribute of that count and never a second session. Rolling membership
* uses canonical instants, while buckets use represented local dates.
* Presentation always ends at the canonical current day, so the bucket
* containing that day remains explicit even when it is zero. */
current_day = dashboard_floor_div(query->reference_unix_second +
INT64_C(719162) * INT64_C(86400), INT64_C(86400));
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
TrainlogTimestampKey key;
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
strlen(sessions[exercise_index].started_at), &key)) {
dashboard->invalid_data = true;
continue;
}
if (!dashboard_in_period(&key, dashboard->period, query->reference_unix_second)) continue;
++dashboard->completed_session_count;
if (key.local_day < earliest_day) earliest_day = key.local_day;
}
dashboard_prepare_period_buckets(dashboard, current_day,
earliest_day == INT64_MAX ? current_day : earliest_day);
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
TrainlogTimestampKey key;
size_t slot;
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
strlen(sessions[exercise_index].started_at), &key) ||
!dashboard_in_period(&key, dashboard->period, query->reference_unix_second)) continue;
slot = dashboard_period_bucket(dashboard, key.local_day);
++dashboard->weeks[slot].sessions;
if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST)
++dashboard->weeks[slot].maxima;
}
if (trainlog_database_list_exercises(database, NULL, 0U,
&exercise_count) != TRAINLOG_STATUS_OK ||
exercise_count > SIZE_MAX / sizeof(*exercises)) {
dashboard->error = true;
goto finish;
}
if (exercise_count > 0U) {
exercises = calloc(exercise_count, sizeof(*exercises));
if (exercises == NULL || trainlog_database_list_exercises(database,
exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
}
if (!dashboard_load_zone_distribution(database, dashboard, exercises, exercise_count)) {
dashboard->error = true;
goto finish;
}
if (dashboard_database_list_facts(database, facts,
TRAINLOG_DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated,
&facts_invalid) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
dashboard->partial = dashboard->partial || facts_truncated;
dashboard->invalid_data = dashboard->invalid_data || facts_invalid;
qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare);
for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) {
TrainlogDashboardFact *fact = &facts[exercise_index];
TrainlogExercise *exercise;
bool external;
bool comparable_work;
bool comparable_max;
size_t prior;
bool has_prior = false;
double prior_best = 0.0;
exercise = dashboard_find_exercise(exercises, exercise_count,
fact->exercise_id);
if (!dashboard_in_period(&fact->time, dashboard->period, query->reference_unix_second)) continue;
if (fact->kind == TRAINLOG_DASHBOARD_WORK)
++dashboard->performed_set_count;
else if (fact->kind == TRAINLOG_DASHBOARD_MAX)
++dashboard->explicit_max_count;
{
size_t seen;
for (seen = 0U; seen < exercise_index; ++seen)
if (dashboard_in_period(&facts[seen].time, dashboard->period, query->reference_unix_second) &&
strcmp(facts[seen].exercise_id, fact->exercise_id) == 0)
break;
if (seen == exercise_index) ++dashboard->distinct_exercise_count;
}
external = dashboard_fact_external_context_qualifies(database, fact);
comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external &&
fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 &&
fact->has_weight && isfinite(fact->weight_kg) &&
fact->weight_kg >= 0.0;
comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external &&
fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0;
/* CONTRACT: Android and TUI compare the same actual observations:
* exact exercise + nonempty resolved external equipment + external
* load semantics + exact performed dose. Only facts at a strictly
* earlier canonical instant establish the prior best; bytewise IDs
* order equal-instant presentation but never manufacture an event. */
if (comparable_work || comparable_max) {
for (prior = 0U; prior < exercise_index; ++prior) {
TrainlogDashboardFact *old = &facts[prior];
bool same = old->kind == fact->kind &&
strcmp(old->exercise_id, fact->exercise_id) == 0 &&
strcmp(old->equipment_id, fact->equipment_id) == 0 &&
old->tracking_mode == fact->tracking_mode &&
old->load_mode == fact->load_mode &&
(fact->kind != TRAINLOG_DASHBOARD_WORK ||
old->dose == fact->dose);
if (!same || trainlog_timestamp_compare(&old->time,
&fact->time) >= 0) continue;
if (!has_prior || old->weight_kg > prior_best)
prior_best = old->weight_kg;
has_prior = true;
}
if (has_prior && fact->weight_kg > prior_best &&
dashboard->week_count > 0U) {
size_t slot = dashboard_period_bucket(dashboard,
fact->time.local_day);
if (comparable_work)
++dashboard->weeks[slot].working_improvements;
else ++dashboard->weeks[slot].max_improvements;
}
}
if (comparable_max && exercise != NULL) {
TrainlogTimestampKey current_max;
int order = 1;
if (dashboard->has_explicit_max &&
trainlog_timestamp_parse(dashboard->max_timestamp,
strlen(dashboard->max_timestamp), &current_max))
order = trainlog_timestamp_compare(&fact->time, &current_max);
if (!dashboard->has_explicit_max || order > 0 ||
(order == 0 && strcmp(fact->exercise_id,
dashboard->max_exercise.exercise_id) < 0)) {
dashboard->has_explicit_max = true;
dashboard->max_exercise = *exercise;
dashboard->max_weight_kg = fact->weight_kg;
(void)snprintf(dashboard->max_timestamp,
sizeof(dashboard->max_timestamp), "%s", fact->started_at);
}
}
if (comparable_work && exercise != NULL) {
int order = has_newest_performance ?
trainlog_timestamp_compare(&fact->time, &newest_performance) : 1;
if (!has_newest_performance || order > 0 ||
(order == 0 && strcmp(fact->exercise_id,
dashboard->exercise.exercise_id) < 0)) {
dashboard->has_performance = true;
dashboard->exercise = *exercise;
dashboard->performance_load_mode = fact->load_mode;
dashboard->performance_tracking_mode = fact->tracking_mode;
dashboard->performance_dose = fact->dose;
(void)snprintf(dashboard->performance_equipment_id,
sizeof(dashboard->performance_equipment_id), "%s",
fact->equipment_id);
newest_performance = fact->time;
has_newest_performance = true;
}
}
}
if (dashboard->has_performance) {
TrainlogResolvedEquipment equipment;
size_t index;
if (trainlog_database_resolve_equipment(database,
dashboard->performance_equipment_id, &equipment) ==
TRAINLOG_STATUS_OK)
(void)snprintf(dashboard->performance_equipment_label,
sizeof(dashboard->performance_equipment_label), "%s",
equipment.display_name);
for (index = 0U; index < fact_count &&
dashboard->performance_count < TRAINLOG_DASHBOARD_SESSION_CAPACITY; ++index) {
TrainlogDashboardFact *fact = &facts[index];
TrainlogExercisePerformancePoint *point;
if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 ||
fact->load_mode != dashboard->performance_load_mode ||
fact->tracking_mode != dashboard->performance_tracking_mode ||
fact->dose != dashboard->performance_dose ||
strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 ||
strcmp(fact->equipment_id,
dashboard->performance_equipment_id) != 0 ||
!dashboard_in_period(&fact->time, dashboard->period, query->reference_unix_second)) continue;
point = &dashboard->performance[dashboard->performance_count++];
(void)memset(point, 0, sizeof(*point));
(void)snprintf(point->session_id, sizeof(point->session_id), "%s",
fact->session_id);
(void)snprintf(point->started_at, sizeof(point->started_at), "%s",
fact->started_at);
point->tracking_mode = fact->tracking_mode;
point->load_mode = fact->load_mode;
point->actual_set_count = 1U;
point->has_performance = 1;
point->metric_value = fact->dose;
point->has_weight = 1;
point->weight_kg = fact->weight_kg;
(void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s",
fact->equipment_id);
}
if (dashboard->performance_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY)
dashboard->partial = true;
qsort(dashboard->performance, dashboard->performance_count,
sizeof(dashboard->performance[0]), dashboard_performance_time_compare);
}
for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) {
size_t count = 0U;
size_t point_index;
if (trainlog_database_list_body_metric_points(database,
DASHBOARD_BODY_METRICS[exercise_index], body,
TRAINLOG_DASHBOARD_BODY_CAPACITY, &count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
continue;
}
if (count == TRAINLOG_DASHBOARD_BODY_CAPACITY) dashboard->partial = true;
for (point_index = 0U; point_index < count; ++point_index) {
TrainlogTimestampKey candidate;
TrainlogTimestampKey current;
int order = 1;
if (!trainlog_timestamp_parse(body[point_index].observed_at,
strlen(body[point_index].observed_at), &candidate)) {
dashboard->invalid_data = true;
continue;
}
if (!dashboard_in_period(&candidate, dashboard->period, query->reference_unix_second)) continue;
if (newest_body[0] != '\0') {
(void)trainlog_timestamp_parse(newest_body, strlen(newest_body), &current);
order = trainlog_timestamp_compare(&candidate, &current);
}
if (newest_body[0] == '\0' || order > 0 ||
(order == 0 && exercise_index < dashboard->body_metric)) {
dashboard->has_body = true;
dashboard->body_metric = exercise_index;
(void)snprintf(newest_body, sizeof(newest_body), "%s",
body[point_index].observed_at);
}
}
}
if (dashboard->has_body) {
size_t count = 0U;
size_t point_index;
if (trainlog_database_list_body_metric_points(database,
DASHBOARD_BODY_METRICS[dashboard->body_metric], body,
TRAINLOG_DASHBOARD_BODY_CAPACITY, &count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
if (count == TRAINLOG_DASHBOARD_BODY_CAPACITY)
dashboard->partial = true;
for (point_index = 0U; point_index < count; ++point_index) {
TrainlogTimestampKey key;
if (!trainlog_timestamp_parse(body[point_index].observed_at,
strlen(body[point_index].observed_at), &key)) {
dashboard->invalid_data = true;
continue;
}
if (dashboard_in_period(&key, dashboard->period, query->reference_unix_second))
dashboard->body[dashboard->body_count++] = body[point_index];
}
qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]),
dashboard_body_time_compare);
}
finish:
if (trainlog_database_read_snapshot_end(database, !dashboard->error) !=
TRAINLOG_STATUS_OK) dashboard->error = true;
free(exercises);
free(facts);
return dashboard->error ? TRAINLOG_STATUS_DATABASE_ERROR : TRAINLOG_STATUS_OK;
}

View file

@ -24,7 +24,6 @@
#include <utf8proc.h>
#include <uuid/uuid.h>
#include <sqlite3.h>
#include "trainlog/terminal.h"
#include "trainlog/app_shell.h"
@ -36,6 +35,7 @@
#include "trainlog/body_zone_catalog.h"
#include "trainlog/catalog.h"
#include "trainlog/duration.h"
#include "trainlog/dashboard.h"
#include "trainlog/equipment_catalog.h"
#include "trainlog/exercise_name_catalog.h"
#include "trainlog/id.h"
@ -53,7 +53,6 @@
#include "trainlog/training_knowledge.h"
#include "trainlog/usb.h"
#include "timestamp.h"
#include "database_internal.h"
static void presentation_surface_printf(TrainlogSurface *surface, int row,
int column, const char *format, ...)
@ -104,9 +103,6 @@ static void presentation_terminal_printf(TrainlogTerminal *terminal, int row,
#define MAX_BODY_METRIC_POINTS 256U
#define EXERCISE_GRAPH_POINTS 12U
#define DASHBOARD_BUCKET_COUNT 12U
#define DASHBOARD_FACT_CAPACITY 4096U
#define DASHBOARD_ZONE_BUCKET_CAPACITY (MAX_BODY_ZONES + 1U)
/* TRAINLOG_TUI_V02_POLISH */
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
@ -2529,87 +2525,6 @@ typedef struct TrainlogSyncController {
TrainlogAppSyncRun run;
} TrainlogSyncController;
typedef struct TrainlogDashboardWeek {
int64_t start_day;
int64_t span_days;
char label[12];
size_t sessions;
size_t maxima;
size_t working_improvements;
size_t max_improvements;
} TrainlogDashboardWeek;
typedef struct TrainlogDashboardZoneBucket {
char label[TRAINLOG_NAME_MAX + 1U];
size_t count;
} TrainlogDashboardZoneBucket;
typedef enum TrainlogStatisticsPeriod {
TRAINLOG_STATS_30_DAYS = 0,
TRAINLOG_STATS_7_DAYS,
TRAINLOG_STATS_90_DAYS,
TRAINLOG_STATS_YEAR,
TRAINLOG_STATS_ALL
} TrainlogStatisticsPeriod;
typedef struct TrainlogDashboardSnapshot {
bool error;
bool partial;
bool invalid_data;
bool has_performance;
TrainlogExercise exercise;
TrainlogLoadMode performance_load_mode;
TrainlogTrackingMode performance_tracking_mode;
int performance_dose;
char performance_equipment_id[TRAINLOG_ID_MAX + 1U];
char performance_equipment_label[TRAINLOG_NAME_MAX + 1U];
TrainlogExercisePerformancePoint performance[MAX_SESSIONS];
size_t performance_count;
bool has_explicit_max;
TrainlogExercise max_exercise;
double max_weight_kg;
char max_timestamp[TRAINLOG_TIMESTAMP_MAX + 1U];
bool has_body;
size_t body_metric;
TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS];
size_t body_count;
TrainlogDashboardWeek weeks[DASHBOARD_BUCKET_COUNT];
size_t week_count;
size_t completed_session_count;
size_t performed_set_count;
size_t distinct_exercise_count;
size_t explicit_max_count;
/* WHY: this is a catalogue dashboard, not a history-derived muscle
* claim. CONTRACT: every active canonical exercise contributes once to
* its persisted primary zone, or to the explicit unclassified bucket.
* INVARIANT: aliases and secondary zones never affect these counts. */
TrainlogDashboardZoneBucket zones[DASHBOARD_ZONE_BUCKET_CAPACITY];
size_t zone_count;
TrainlogStatisticsPeriod period;
} TrainlogDashboardSnapshot;
typedef enum TrainlogDashboardFactKind {
TRAINLOG_DASHBOARD_WORK,
TRAINLOG_DASHBOARD_MAX,
TRAINLOG_DASHBOARD_CONTINUOUS
} TrainlogDashboardFactKind;
typedef struct TrainlogDashboardFact {
TrainlogDashboardFactKind kind;
TrainlogTimestampKey time;
char session_id[TRAINLOG_ID_MAX + 1U];
char entry_id[TRAINLOG_ID_MAX + 1U];
char exercise_id[TRAINLOG_ID_MAX + 1U];
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
char equipment_id[TRAINLOG_ID_MAX + 1U];
TrainlogTrackingMode tracking_mode;
TrainlogLoadMode load_mode;
size_t set_position;
int dose;
bool has_weight;
double weight_kg;
} TrainlogDashboardFact;
typedef struct TrainlogAppContext {
TrainlogDatabase *database;
TrainlogTerminal *terminal;
@ -3665,691 +3580,48 @@ static void app_shell_load_body_analytics(TrainlogAppContext *app)
app->body_has_profile = body_analytics_profile_load(&app->body_profile);
}
static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column,
char *output, size_t capacity)
{
const unsigned char *value;
int bytes;
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return false;
value = sqlite3_column_text(statement, column);
bytes = sqlite3_column_bytes(statement, column);
if (value == NULL || bytes < 0 || (size_t)bytes >= capacity) return false;
(void)memcpy(output, value, (size_t)bytes);
output[bytes] = '\0';
return true;
}
/* WHY: the global dashboard compares occurrence-owned facts, while the public
* performance reader deliberately collapses each session to one representative.
* CONTRACT: this private projection emits every performed set, every explicit
* MAX exactly once, and every continuous occurrence exactly once. INVARIANT:
* malformed legacy timestamps are omitted before sorting and reported through
* invalid_data; they can never reach a comparator with uninitialized keys. */
static TrainlogStatus dashboard_database_list_facts(TrainlogDatabase *database,
TrainlogDashboardFact *output, size_t capacity, size_t *output_count,
bool *truncated, bool *invalid_data)
{
static const char *const SQL =
"SELECT 0,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,ps.position,"
"CASE WHEN se.tracking_mode='reps' THEN ps.reps ELSE ps.duration_seconds END,"
"ps.weight_kg FROM performed_sets ps "
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 1,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,0,"
"mr.max_weight_kg FROM max_results mr "
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id "
"UNION ALL "
"SELECT 2,s.started_at,s.session_id,se.entry_id,e.exercise_id,"
"COALESCE(se.equipment_id,''),se.tracking_mode,se.load_mode,0,"
"ca.duration_seconds,NULL FROM continuous_activity ca "
"JOIN session_exercises se ON se.id=ca.session_exercise_row_id "
"JOIN sessions s ON s.id=se.session_row_id "
"JOIN exercises e ON e.id=se.exercise_row_id;";
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int rc;
if (output_count != NULL) *output_count = 0U;
if (truncated != NULL) *truncated = false;
if (invalid_data != NULL) *invalid_data = false;
if (database == NULL || database->connection == NULL || output_count == NULL ||
truncated == NULL || invalid_data == NULL ||
(capacity > 0U && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
rc = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
if (rc != SQLITE_OK) return TRAINLOG_STATUS_DATABASE_ERROR;
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
TrainlogDashboardFact fact;
const unsigned char *tracking;
const unsigned char *load;
int kind;
(void)memset(&fact, 0, sizeof(fact));
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER ||
!dashboard_copy_sql_text(statement, 1, fact.started_at,
sizeof(fact.started_at)) ||
!dashboard_copy_sql_text(statement, 2, fact.session_id,
sizeof(fact.session_id)) ||
!dashboard_copy_sql_text(statement, 3, fact.entry_id,
sizeof(fact.entry_id)) ||
!dashboard_copy_sql_text(statement, 4, fact.exercise_id,
sizeof(fact.exercise_id)) ||
!dashboard_copy_sql_text(statement, 5, fact.equipment_id,
sizeof(fact.equipment_id)) ||
sqlite3_column_type(statement, 6) != SQLITE_TEXT ||
sqlite3_column_type(statement, 7) != SQLITE_TEXT ||
!trainlog_timestamp_parse(fact.started_at, strlen(fact.started_at),
&fact.time)) {
*invalid_data = true;
continue;
}
kind = sqlite3_column_int(statement, 0);
if (kind < 0 || kind > 2) {
*invalid_data = true;
continue;
}
fact.kind = (TrainlogDashboardFactKind)kind;
tracking = sqlite3_column_text(statement, 6);
load = sqlite3_column_text(statement, 7);
if (strcmp((const char *)tracking, "reps") == 0)
fact.tracking_mode = TRAINLOG_TRACKING_REPS;
else if (strcmp((const char *)tracking, "duration") == 0)
fact.tracking_mode = TRAINLOG_TRACKING_DURATION;
else { *invalid_data = true; continue; }
if (strcmp((const char *)load, "none") == 0)
fact.load_mode = TRAINLOG_LOAD_NONE;
else if (strcmp((const char *)load, "external") == 0)
fact.load_mode = TRAINLOG_LOAD_EXTERNAL;
else if (strcmp((const char *)load, "assistance") == 0)
fact.load_mode = TRAINLOG_LOAD_ASSISTANCE;
else { *invalid_data = true; continue; }
if (fact.kind == TRAINLOG_DASHBOARD_WORK) {
sqlite3_int64 position;
if (sqlite3_column_type(statement, 8) != SQLITE_INTEGER ||
sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
*invalid_data = true; continue;
}
position = sqlite3_column_int64(statement, 8);
if (position < 0 || (uint64_t)position > (uint64_t)SIZE_MAX) {
*invalid_data = true; continue;
}
fact.set_position = (size_t)position;
fact.dose = sqlite3_column_int(statement, 9);
} else if (fact.kind == TRAINLOG_DASHBOARD_CONTINUOUS) {
if (sqlite3_column_type(statement, 9) != SQLITE_INTEGER) {
*invalid_data = true; continue;
}
fact.dose = sqlite3_column_int(statement, 9);
}
if (fact.kind != TRAINLOG_DASHBOARD_CONTINUOUS) {
int type = sqlite3_column_type(statement, 10);
if (type == SQLITE_NULL && fact.kind == TRAINLOG_DASHBOARD_WORK) {
fact.has_weight = false;
} else if ((type != SQLITE_FLOAT && type != SQLITE_INTEGER) ||
!isfinite(sqlite3_column_double(statement, 10))) {
*invalid_data = true; continue;
} else {
fact.has_weight = true;
fact.weight_kg = sqlite3_column_double(statement, 10);
}
if (fact.kind == TRAINLOG_DASHBOARD_MAX && fact.weight_kg <= 0.0) {
*invalid_data = true; continue;
}
}
if (count == capacity) { *truncated = true; continue; }
output[count++] = fact;
}
if (rc != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
return TRAINLOG_STATUS_DATABASE_ERROR;
*output_count = count;
return TRAINLOG_STATUS_OK;
}
static int dashboard_fact_time_compare(const void *left, const void *right)
{
const TrainlogDashboardFact *a = left;
const TrainlogDashboardFact *b = right;
int result = trainlog_timestamp_compare(&a->time, &b->time);
if (result != 0) return result;
result = strcmp(a->session_id, b->session_id);
if (result != 0) return result;
result = strcmp(a->entry_id, b->entry_id);
if (result != 0) return result;
if (a->kind != b->kind) return a->kind < b->kind ? -1 : 1;
return a->set_position == b->set_position ? 0 :
(a->set_position < b->set_position ? -1 : 1);
}
static int dashboard_performance_time_compare(const void *left, const void *right)
{
const TrainlogExercisePerformancePoint *a = left;
const TrainlogExercisePerformancePoint *b = right;
TrainlogTimestampKey a_key;
TrainlogTimestampKey b_key;
(void)trainlog_timestamp_parse(a->started_at, strlen(a->started_at), &a_key);
(void)trainlog_timestamp_parse(b->started_at, strlen(b->started_at), &b_key);
{
int result = trainlog_timestamp_compare(&a_key, &b_key);
return result != 0 ? result : strcmp(a->session_id, b->session_id);
}
}
static int dashboard_body_time_compare(const void *left, const void *right)
{
const TrainlogBodyMetricPoint *a = left;
const TrainlogBodyMetricPoint *b = right;
TrainlogTimestampKey a_key;
TrainlogTimestampKey b_key;
(void)trainlog_timestamp_parse(a->observed_at, strlen(a->observed_at), &a_key);
(void)trainlog_timestamp_parse(b->observed_at, strlen(b->observed_at), &b_key);
return trainlog_timestamp_compare(&a_key, &b_key);
}
static int64_t dashboard_floor_div(int64_t value, int64_t divisor)
{
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
}
static int64_t dashboard_period_days(TrainlogStatisticsPeriod period)
static const char *dashboard_period_bucket_summary(TrainlogDashboardPeriod period)
{
switch (period) {
case TRAINLOG_STATS_7_DAYS: return 7;
case TRAINLOG_STATS_30_DAYS: return 30;
case TRAINLOG_STATS_90_DAYS: return 90;
case TRAINLOG_STATS_YEAR: return 365;
case TRAINLOG_STATS_ALL: return 0;
}
return 30;
}
/* WHY: characterization tests must exercise rolling-window boundaries without
* depending on wall-clock execution time. CONTRACT: production always reads
* time(NULL); only the translation-unit test build may supply a fixed instant.
* INVARIANT: the override is absent from the installed binary and cannot alter
* persisted data or Dashboard semantics. */
#ifdef TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST
static bool dashboard_characterization_clock_enabled;
static time_t dashboard_characterization_clock_value;
#endif
static time_t dashboard_clock_now(void)
{
#ifdef TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST
if (dashboard_characterization_clock_enabled)
return dashboard_characterization_clock_value;
#endif
return time(NULL);
}
static bool dashboard_in_period(const TrainlogTimestampKey *key,
TrainlogStatisticsPeriod period)
{
const int64_t days = dashboard_period_days(period);
/* Unix time and Trainlog's proleptic day zero differ by 719162 days. */
const int64_t now = (int64_t)dashboard_clock_now() +
INT64_C(719162) * INT64_C(86400);
return key->utc_second <= now &&
(days == 0 || key->utc_second >= now - days * INT64_C(86400));
}
/* WHY: the selected period must change both dashboard charts, while the
* canonical rolling membership test above remains the sole inclusion rule.
* CONTRACT: 7/30/90/365 days use respectively daily, five-day, fifteen-day
* and roughly monthly (31-day) presentation buckets; Tout uses at most twelve
* equal day spans covering represented history. INVARIANT: every included
* local date is clamped into exactly one leading-to-current bucket. */
static void dashboard_prepare_period_buckets(TrainlogDashboardSnapshot *dashboard,
int64_t current_day, int64_t earliest_day)
{
size_t count;
int64_t span;
size_t index;
switch (dashboard->period) {
case TRAINLOG_STATS_7_DAYS: count = 7U; span = 1; break;
case TRAINLOG_STATS_30_DAYS: count = 6U; span = 5; break;
case TRAINLOG_STATS_90_DAYS: count = 6U; span = 15; break;
case TRAINLOG_STATS_YEAR: count = 12U; span = 31; break;
case TRAINLOG_STATS_ALL: {
int64_t represented = current_day >= earliest_day
? current_day - earliest_day + 1 : 1;
count = represented < (int64_t)DASHBOARD_BUCKET_COUNT
? (size_t)represented : DASHBOARD_BUCKET_COUNT;
if (count == 0U) count = 1U;
span = (represented + (int64_t)count - 1) / (int64_t)count;
break;
}
}
dashboard->week_count = count;
for (index = 0U; index < count; ++index) {
int64_t distance = (int64_t)(count - 1U - index) * span;
dashboard->weeks[index].start_day = current_day - distance - span + 1;
dashboard->weeks[index].span_days = span;
if (index + 1U == count)
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "%s", "actuel");
else if (dashboard->period == TRAINLOG_STATS_7_DAYS)
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "J−%zu", count - 1U - index);
else if (dashboard->period == TRAINLOG_STATS_YEAR)
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "M−%zu", count - 1U - index);
else
(void)snprintf(dashboard->weeks[index].label,
sizeof(dashboard->weeks[index].label), "P−%zu", count - 1U - index);
}
}
static size_t dashboard_period_bucket(const TrainlogDashboardSnapshot *dashboard,
int64_t local_day)
{
int64_t first;
int64_t span;
size_t slot;
if (dashboard->week_count == 0U) return 0U;
first = dashboard->weeks[0].start_day;
span = dashboard->weeks[0].span_days;
if (local_day <= first) return 0U;
slot = (size_t)((local_day - first) / span);
return slot < dashboard->week_count ? slot : dashboard->week_count - 1U;
}
static const char *dashboard_period_bucket_summary(TrainlogStatisticsPeriod period)
{
switch (period) {
case TRAINLOG_STATS_7_DAYS: return "7 jours · quotidien";
case TRAINLOG_STATS_30_DAYS: return "30 jours · pas de 5 j";
case TRAINLOG_STATS_90_DAYS: return "90 jours · pas de 15 j";
case TRAINLOG_STATS_YEAR: return "1 an · pas de 31 j";
case TRAINLOG_STATS_ALL: return "Tout · 12 périodes max.";
case TRAINLOG_DASHBOARD_7_DAYS: return "7 jours · quotidien";
case TRAINLOG_DASHBOARD_30_DAYS: return "30 jours · pas de 5 j";
case TRAINLOG_DASHBOARD_90_DAYS: return "90 jours · pas de 15 j";
case TRAINLOG_DASHBOARD_YEAR: return "1 an · pas de 31 j";
case TRAINLOG_DASHBOARD_ALL: return "Tout · 12 périodes max.";
}
return "Période";
}
static bool dashboard_fact_external_context_qualifies(TrainlogAppContext *app,
const TrainlogDashboardFact *fact)
static void dashboard_period_bucket_label(TrainlogDashboardPeriod period,
size_t index, size_t count, char output[12])
{
TrainlogResolvedEquipment equipment;
if (fact->equipment_id[0] == '\0' ||
trainlog_database_resolve_equipment(app->database, fact->equipment_id,
&equipment) != TRAINLOG_STATUS_OK) return false;
return strcmp(equipment.load_semantics, "external") == 0;
}
static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises,
size_t count, const char *exercise_id)
{
size_t index;
for (index = 0U; index < count; ++index)
if (strcmp(exercises[index].exercise_id, exercise_id) == 0)
return &exercises[index];
return NULL;
}
static int dashboard_zone_bucket_compare(const void *left, const void *right)
{
const TrainlogDashboardZoneBucket *a = left;
const TrainlogDashboardZoneBucket *b = right;
if (a->count != b->count) return a->count > b->count ? -1 : 1;
return strcmp(a->label, b->label);
}
/* WHY: a body-zone dashboard must describe the current catalogue rather than
* infer classifications from history or commercial names. CONTRACT: one
* canonical exercise is counted once, using only its persisted primary body
* zone. INVARIANT: missing primary relations remain visible as "Non classés";
* secondary relations and aliases can neither create nor duplicate a bucket. */
static bool dashboard_load_zone_distribution(TrainlogAppContext *app,
const TrainlogExercise *exercises, size_t exercise_count)
{
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
size_t exercise_index;
for (exercise_index = 0U; exercise_index < exercise_count; ++exercise_index) {
TrainlogExerciseBodyZone relations[MAX_BODY_ZONES];
const char *label = "Non classés";
size_t relation_count = 0U;
size_t relation_index;
size_t bucket;
if (trainlog_database_list_exercise_body_zones(app->database,
exercises[exercise_index].exercise_id, relations, MAX_BODY_ZONES,
&relation_count) != TRAINLOG_STATUS_OK) return false;
for (relation_index = 0U; relation_index < relation_count; ++relation_index) {
if (relations[relation_index].role == TRAINLOG_BODY_ZONE_PRIMARY) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
relations[relation_index].zone_id);
if (zone != NULL && !zone->is_group) label = zone->display_name;
break;
}
}
for (bucket = 0U; bucket < dashboard->zone_count; ++bucket)
if (strcmp(dashboard->zones[bucket].label, label) == 0) break;
if (bucket == dashboard->zone_count) {
if (bucket == DASHBOARD_ZONE_BUCKET_CAPACITY) return false;
(void)snprintf(dashboard->zones[bucket].label,
sizeof(dashboard->zones[bucket].label), "%s", label);
++dashboard->zone_count;
}
++dashboard->zones[bucket].count;
if (index + 1U == count) {
(void)snprintf(output, 12U, "%s",
trainlog_presentation_text("dashboard.current"));
} else if (period == TRAINLOG_DASHBOARD_7_DAYS) {
(void)snprintf(output, 12U, "J−%zu", count - 1U - index);
} else if (period == TRAINLOG_DASHBOARD_YEAR) {
(void)snprintf(output, 12U, "M−%zu", count - 1U - index);
} else {
(void)snprintf(output, 12U, "P−%zu", count - 1U - index);
}
qsort(dashboard->zones, dashboard->zone_count, sizeof(dashboard->zones[0]),
dashboard_zone_bucket_compare);
return true;
}
/* WHY: the landing page must remain useful without rendering-time SQL.
* CONTRACT: this bounded controller snapshot contains only actual persisted
* facts. One working-performance series has an exact exercise/equipment/load
* identity; explicit MAX is separate, and assistance/planned targets never
* enter it. All chronology is decided by Trainlog's timestamp parser, not
* SQLite text ordering. */
/* The TUI owns only the reference clock and selected presentation period. */
static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app)
{
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
TrainlogStatisticsPeriod period = dashboard->period;
TrainlogExercise *exercises = NULL;
TrainlogDashboardFact *facts = NULL;
TrainlogBodyMetricPoint body[MAX_BODY_METRIC_POINTS];
TrainlogSessionSummary sessions[MAX_SESSIONS];
TrainlogTimestampKey newest_performance;
char newest_body[TRAINLOG_TIMESTAMP_MAX + 1U] = "";
size_t exercise_count = 0U;
size_t fact_count = 0U;
size_t session_count = 0U;
size_t exercise_index;
int64_t current_day;
int64_t earliest_day = INT64_MAX;
time_t now;
bool has_newest_performance = false;
bool facts_truncated = false;
bool facts_invalid = false;
(void)memset(dashboard, 0, sizeof(*dashboard));
dashboard->period = period;
facts = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*facts));
if (facts == NULL) {
dashboard->error = true;
return;
}
if (trainlog_database_read_snapshot_begin(app->database) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
free(facts);
return;
}
if (trainlog_database_list_sessions(app->database, sessions, MAX_SESSIONS,
&session_count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
dashboard->partial = session_count == MAX_SESSIONS;
/* CONTRACT: frequency is one count per actual session; a MAX marker is an
* attribute of that count and never a second session. Rolling membership
* uses canonical instants, while buckets use represented local dates.
* Presentation always ends at the canonical current day, so the bucket
* containing that day remains explicit even when it is zero. */
now = dashboard_clock_now();
TrainlogDashboardQuery query;
TrainlogDashboardPeriod period = app->dashboard.period;
time_t now = time(NULL);
query.period = period;
if (now == (time_t)-1) {
dashboard->error = true;
goto finish;
(void)memset(&app->dashboard, 0, sizeof(app->dashboard));
app->dashboard.period = period;
app->dashboard.error = true;
return;
}
current_day = dashboard_floor_div((int64_t)now +
INT64_C(719162) * INT64_C(86400), INT64_C(86400));
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
TrainlogTimestampKey key;
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
strlen(sessions[exercise_index].started_at), &key)) {
dashboard->invalid_data = true;
continue;
}
if (!dashboard_in_period(&key, dashboard->period)) continue;
++dashboard->completed_session_count;
if (key.local_day < earliest_day) earliest_day = key.local_day;
}
dashboard_prepare_period_buckets(dashboard, current_day,
earliest_day == INT64_MAX ? current_day : earliest_day);
for (exercise_index = 0U; exercise_index < session_count; ++exercise_index) {
TrainlogTimestampKey key;
size_t slot;
if (!trainlog_timestamp_parse(sessions[exercise_index].started_at,
strlen(sessions[exercise_index].started_at), &key) ||
!dashboard_in_period(&key, dashboard->period)) continue;
slot = dashboard_period_bucket(dashboard, key.local_day);
++dashboard->weeks[slot].sessions;
if (sessions[exercise_index].session_type == TRAINLOG_SESSION_MAX_TEST)
++dashboard->weeks[slot].maxima;
}
if (trainlog_database_list_exercises(app->database, NULL, 0U,
&exercise_count) != TRAINLOG_STATUS_OK ||
exercise_count > SIZE_MAX / sizeof(*exercises)) {
dashboard->error = true;
goto finish;
}
if (exercise_count > 0U) {
exercises = calloc(exercise_count, sizeof(*exercises));
if (exercises == NULL || trainlog_database_list_exercises(app->database,
exercises, exercise_count, &exercise_count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
}
if (!dashboard_load_zone_distribution(app, exercises, exercise_count)) {
dashboard->error = true;
goto finish;
}
if (dashboard_database_list_facts(app->database, facts,
DASHBOARD_FACT_CAPACITY, &fact_count, &facts_truncated,
&facts_invalid) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
dashboard->partial = dashboard->partial || facts_truncated;
dashboard->invalid_data = dashboard->invalid_data || facts_invalid;
qsort(facts, fact_count, sizeof(facts[0]), dashboard_fact_time_compare);
for (exercise_index = 0U; exercise_index < fact_count; ++exercise_index) {
TrainlogDashboardFact *fact = &facts[exercise_index];
TrainlogExercise *exercise;
bool external;
bool comparable_work;
bool comparable_max;
size_t prior;
bool has_prior = false;
double prior_best = 0.0;
exercise = dashboard_find_exercise(exercises, exercise_count,
fact->exercise_id);
if (!dashboard_in_period(&fact->time, dashboard->period)) continue;
if (fact->kind == TRAINLOG_DASHBOARD_WORK)
++dashboard->performed_set_count;
else if (fact->kind == TRAINLOG_DASHBOARD_MAX)
++dashboard->explicit_max_count;
{
size_t seen;
for (seen = 0U; seen < exercise_index; ++seen)
if (dashboard_in_period(&facts[seen].time, dashboard->period) &&
strcmp(facts[seen].exercise_id, fact->exercise_id) == 0)
break;
if (seen == exercise_index) ++dashboard->distinct_exercise_count;
}
external = dashboard_fact_external_context_qualifies(app, fact);
comparable_work = fact->kind == TRAINLOG_DASHBOARD_WORK && external &&
fact->load_mode == TRAINLOG_LOAD_EXTERNAL && fact->dose > 0 &&
fact->has_weight && isfinite(fact->weight_kg) &&
fact->weight_kg >= 0.0;
comparable_max = fact->kind == TRAINLOG_DASHBOARD_MAX && external &&
fact->load_mode != TRAINLOG_LOAD_ASSISTANCE && fact->weight_kg > 0.0;
/* CONTRACT: Android and TUI compare the same actual observations:
* exact exercise + nonempty resolved external equipment + external
* load semantics + exact performed dose. Only facts at a strictly
* earlier canonical instant establish the prior best; bytewise IDs
* order equal-instant presentation but never manufacture an event. */
if (comparable_work || comparable_max) {
for (prior = 0U; prior < exercise_index; ++prior) {
TrainlogDashboardFact *old = &facts[prior];
bool same = old->kind == fact->kind &&
strcmp(old->exercise_id, fact->exercise_id) == 0 &&
strcmp(old->equipment_id, fact->equipment_id) == 0 &&
old->tracking_mode == fact->tracking_mode &&
old->load_mode == fact->load_mode &&
(fact->kind != TRAINLOG_DASHBOARD_WORK ||
old->dose == fact->dose);
if (!same || trainlog_timestamp_compare(&old->time,
&fact->time) >= 0) continue;
if (!has_prior || old->weight_kg > prior_best)
prior_best = old->weight_kg;
has_prior = true;
}
if (has_prior && fact->weight_kg > prior_best &&
dashboard->week_count > 0U) {
size_t slot = dashboard_period_bucket(dashboard,
fact->time.local_day);
if (comparable_work)
++dashboard->weeks[slot].working_improvements;
else ++dashboard->weeks[slot].max_improvements;
}
}
if (comparable_max && exercise != NULL) {
TrainlogTimestampKey current_max;
int order = 1;
if (dashboard->has_explicit_max &&
trainlog_timestamp_parse(dashboard->max_timestamp,
strlen(dashboard->max_timestamp), &current_max))
order = trainlog_timestamp_compare(&fact->time, &current_max);
if (!dashboard->has_explicit_max || order > 0 ||
(order == 0 && strcmp(fact->exercise_id,
dashboard->max_exercise.exercise_id) < 0)) {
dashboard->has_explicit_max = true;
dashboard->max_exercise = *exercise;
dashboard->max_weight_kg = fact->weight_kg;
(void)snprintf(dashboard->max_timestamp,
sizeof(dashboard->max_timestamp), "%s", fact->started_at);
}
}
if (comparable_work && exercise != NULL) {
int order = has_newest_performance ?
trainlog_timestamp_compare(&fact->time, &newest_performance) : 1;
if (!has_newest_performance || order > 0 ||
(order == 0 && strcmp(fact->exercise_id,
dashboard->exercise.exercise_id) < 0)) {
dashboard->has_performance = true;
dashboard->exercise = *exercise;
dashboard->performance_load_mode = fact->load_mode;
dashboard->performance_tracking_mode = fact->tracking_mode;
dashboard->performance_dose = fact->dose;
(void)snprintf(dashboard->performance_equipment_id,
sizeof(dashboard->performance_equipment_id), "%s",
fact->equipment_id);
newest_performance = fact->time;
has_newest_performance = true;
}
}
}
if (dashboard->has_performance) {
TrainlogResolvedEquipment equipment;
size_t index;
if (trainlog_database_resolve_equipment(app->database,
dashboard->performance_equipment_id, &equipment) ==
TRAINLOG_STATUS_OK)
(void)snprintf(dashboard->performance_equipment_label,
sizeof(dashboard->performance_equipment_label), "%s",
equipment.display_name);
for (index = 0U; index < fact_count &&
dashboard->performance_count < MAX_SESSIONS; ++index) {
TrainlogDashboardFact *fact = &facts[index];
TrainlogExercisePerformancePoint *point;
if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 ||
fact->load_mode != dashboard->performance_load_mode ||
fact->tracking_mode != dashboard->performance_tracking_mode ||
fact->dose != dashboard->performance_dose ||
strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 ||
strcmp(fact->equipment_id,
dashboard->performance_equipment_id) != 0 ||
!dashboard_in_period(&fact->time, dashboard->period)) continue;
point = &dashboard->performance[dashboard->performance_count++];
(void)memset(point, 0, sizeof(*point));
(void)snprintf(point->session_id, sizeof(point->session_id), "%s",
fact->session_id);
(void)snprintf(point->started_at, sizeof(point->started_at), "%s",
fact->started_at);
point->tracking_mode = fact->tracking_mode;
point->load_mode = fact->load_mode;
point->actual_set_count = 1U;
point->has_performance = 1;
point->metric_value = fact->dose;
point->has_weight = 1;
point->weight_kg = fact->weight_kg;
(void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s",
fact->equipment_id);
}
if (dashboard->performance_count == MAX_SESSIONS)
dashboard->partial = true;
qsort(dashboard->performance, dashboard->performance_count,
sizeof(dashboard->performance[0]), dashboard_performance_time_compare);
}
for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) {
size_t count = 0U;
size_t point_index;
if (trainlog_database_list_body_metric_points(app->database,
BODY_METRICS[exercise_index].metric, body,
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
continue;
}
if (count == MAX_BODY_METRIC_POINTS) dashboard->partial = true;
for (point_index = 0U; point_index < count; ++point_index) {
TrainlogTimestampKey candidate;
TrainlogTimestampKey current;
int order = 1;
if (!trainlog_timestamp_parse(body[point_index].observed_at,
strlen(body[point_index].observed_at), &candidate)) {
dashboard->invalid_data = true;
continue;
}
if (!dashboard_in_period(&candidate, dashboard->period)) continue;
if (newest_body[0] != '\0') {
(void)trainlog_timestamp_parse(newest_body, strlen(newest_body), &current);
order = trainlog_timestamp_compare(&candidate, &current);
}
if (newest_body[0] == '\0' || order > 0 ||
(order == 0 && exercise_index < dashboard->body_metric)) {
dashboard->has_body = true;
dashboard->body_metric = exercise_index;
(void)snprintf(newest_body, sizeof(newest_body), "%s",
body[point_index].observed_at);
}
}
}
if (dashboard->has_body) {
size_t count = 0U;
size_t point_index;
if (trainlog_database_list_body_metric_points(app->database,
BODY_METRICS[dashboard->body_metric].metric, body,
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
dashboard->error = true;
goto finish;
}
if (count == MAX_BODY_METRIC_POINTS)
dashboard->partial = true;
for (point_index = 0U; point_index < count; ++point_index) {
TrainlogTimestampKey key;
if (!trainlog_timestamp_parse(body[point_index].observed_at,
strlen(body[point_index].observed_at), &key)) {
dashboard->invalid_data = true;
continue;
}
if (dashboard_in_period(&key, dashboard->period))
dashboard->body[dashboard->body_count++] = body[point_index];
}
qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]),
dashboard_body_time_compare);
}
finish:
if (trainlog_database_read_snapshot_end(app->database, !dashboard->error) !=
TRAINLOG_STATUS_OK) dashboard->error = true;
free(exercises);
free(facts);
query.reference_unix_second = (int64_t)now;
(void)trainlog_dashboard_load(app->database, &query, &app->dashboard);
}
static void sync_controller_load_history(TrainlogSyncController *sync)
@ -6442,6 +5714,11 @@ static void app_shell_dashboard_zone_chart(TrainlogAppContext *app,
if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows;
}
for (index = 0U; index < bucket_rows; ++index) {
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
dashboard->zones[index].zone_id);
const char *label = dashboard->zones[index].zone_id[0] == '\0'
? trainlog_presentation_text("ui.unclassified")
: (zone == NULL ? dashboard->zones[index].zone_id : zone->display_name);
int row = top + (int)index * row_stride;
int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count *
(size_t)bar_width + maximum - 1U) / maximum);
@ -6450,7 +5727,7 @@ static void app_shell_dashboard_zone_chart(TrainlogAppContext *app,
* independently scaled partial-height rows can never form the former
* solid staircase. Wide layouts leave a blank row between buckets. */
trainlog_surface_printf(app->content, row, left,
"%-*.*s", label_width, label_width, dashboard->zones[index].label);
"%-*.*s", label_width, label_width, label);
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
for (block = 0; block < blocks; ++block)
@ -6496,9 +5773,9 @@ static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app,
dashboard->weeks[dashboard->week_count - 1U].maxima);
baseline = top + height;
for (index = 0U; index < dashboard->week_count; ++index) {
const char *label = index + 1U == dashboard->week_count
? trainlog_presentation_text("dashboard.current")
: dashboard->weeks[index].label;
char label[12];
dashboard_period_bucket_label(dashboard->period, index,
dashboard->week_count, label);
int label_margin = 3;
int x = dashboard->week_count > 1U
? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) /
@ -6546,12 +5823,12 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex
body_values[index] = dashboard->body[index].value;
trainlog_surface_printf(app->content, 2, 2,
"Période %s %s %s %s %s",
dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j",
dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j",
dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j",
dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an",
dashboard->period == TRAINLOG_DASHBOARD_7_DAYS ? "[7j]" : "7j",
dashboard->period == TRAINLOG_DASHBOARD_30_DAYS ? "[30j]" : "30j",
dashboard->period == TRAINLOG_DASHBOARD_90_DAYS ? "[90j]" : "90j",
dashboard->period == TRAINLOG_DASHBOARD_YEAR ? "[1an]" : "1an",
trainlog_presentation_source(
dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout"));
dashboard->period == TRAINLOG_DASHBOARD_ALL ? "[Tout]" : "Tout"));
trainlog_surface_printf(app->content, 3, 2,
"Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu",
dashboard->completed_session_count, dashboard->performed_set_count,

514
tui/tests/test_dashboard.c Normal file
View file

@ -0,0 +1,514 @@
/** @file test_dashboard.c @brief Core Dashboard characterization parity. */
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "trainlog/dashboard.h"
#define CHECK(condition) do { if (!(condition)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #condition); return false; } } while (0)
static bool dashboard_load(TrainlogDatabase *database,
TrainlogDashboardPeriod period, TrainlogDashboardSnapshot *snapshot)
{
const TrainlogDashboardQuery query = {period, INT64_C(1789560000)};
return trainlog_dashboard_load(database, &query, snapshot) ==
TRAINLOG_STATUS_OK;
}
static bool dashboard_insert_completed_set_dose(TrainlogDatabase *database,
const char *session_id, const char *started_at, const char *ended_at,
TrainlogLoadMode load_mode, const char *equipment_id, double weight, int dose)
{
TrainlogSetInput set = {dose, 0, true, weight};
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
"ex_33333333-3333-4333-8333-333333333333");
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
equipment_id);
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.load_mode = load_mode;
occurrence.rest_seconds = 60;
occurrence.target_sets = 2;
occurrence.target_reps = 10;
occurrence.target_has_weight = true;
occurrence.target_weight_kg = 999.0;
occurrence.sets = &set;
occurrence.set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at);
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool dashboard_insert_completed_set(TrainlogDatabase *database,
const char *session_id, const char *started_at, const char *ended_at,
TrainlogLoadMode load_mode, const char *equipment_id, double weight)
{
return dashboard_insert_completed_set_dose(database, session_id, started_at,
ended_at, load_mode, equipment_id, weight, 8);
}
static bool dashboard_insert_max_for_exercise(TrainlogDatabase *database,
const char *session_id, const char *entry_id, const char *exercise_id,
const char *started_at, double weight)
{
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id);
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
exercise_id);
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
"leg_press");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_NONE;
occurrence.has_max_weight = true;
occurrence.max_weight_kg = weight;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
session.session_type = TRAINLOG_SESSION_MAX_TEST;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool dashboard_insert_plan_for_exercise(TrainlogDatabase *database,
const char *session_id, const char *exercise_id, const char *started_at)
{
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
exercise_id);
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
"leg_press");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrence.target_sets = 3;
occurrence.target_reps = 10;
occurrence.target_has_weight = true;
occurrence.target_weight_kg = 50.0;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool dashboard_insert_optional_weight_set(TrainlogDatabase *database,
const char *session_id, const char *exercise_id, const char *started_at,
bool has_weight, double weight)
{
TrainlogSetInput set = {10, 0, has_weight, weight};
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
exercise_id);
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
"leg_press");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrence.target_sets = 1;
occurrence.target_reps = 10;
occurrence.target_has_weight = true;
occurrence.target_weight_kg = 25.0;
occurrence.sets = &set;
occurrence.set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool test_dashboard_characterization_empty_and_window_boundaries(void)
{
TrainlogDatabase *database = NULL;
TrainlogDashboardSnapshot dashboard;
size_t index;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
(void)memset(&dashboard, 0, sizeof(dashboard));
dashboard.period = TRAINLOG_DASHBOARD_30_DAYS;
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && !dashboard.partial &&
!dashboard.invalid_data);
CHECK(!dashboard.has_performance && !dashboard.has_explicit_max &&
!dashboard.has_body);
CHECK(dashboard.performance_count == 0U && dashboard.body_count == 0U);
CHECK(dashboard.completed_session_count == 0U &&
dashboard.performed_set_count == 0U &&
dashboard.distinct_exercise_count == 0U &&
dashboard.explicit_max_count == 0U);
/* The database is empty of training history, not of the seeded canonical
* catalogue: zone distribution is therefore deliberately non-empty. */
CHECK(dashboard.zone_count == 4U);
CHECK(dashboard.zones[0].zone_id[0] == '\0' &&
dashboard.zones[0].count == 2U);
CHECK(dashboard.week_count == 6U);
for (index = 0U; index < dashboard.week_count; ++index)
CHECK(dashboard.weeks[index].sessions == 0U &&
dashboard.weeks[index].maxima == 0U &&
dashboard.weeks[index].working_improvements == 0U &&
dashboard.weeks[index].max_improvements == 0U);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Frontière",
"frontiere", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_completed_set(database,
"se_31000000-0000-4000-8000-000000000001",
"2026-08-17T11:59:59Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0));
CHECK(dashboard_insert_completed_set(database,
"se_31000000-0000-4000-8000-000000000002",
"2026-08-17T12:00:00Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0));
CHECK(dashboard_insert_completed_set(database,
"se_31000000-0000-4000-8000-000000000003",
"2026-08-17T12:00:01Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 30.0));
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && !dashboard.invalid_data);
CHECK(dashboard.completed_session_count == 2U);
CHECK(dashboard.performed_set_count == 2U);
CHECK(dashboard.distinct_exercise_count == 1U);
CHECK(dashboard.has_performance && dashboard.performance_count == 2U);
CHECK(strcmp(dashboard.performance[0].started_at,
"2026-08-17T12:00:00Z") == 0);
CHECK(strcmp(dashboard.performance[1].started_at,
"2026-08-17T12:00:01Z") == 0);
trainlog_database_close(database);
return true;
}
static bool test_dashboard_characterization_modalities_ties_and_zones(void)
{
static const char *const primary_secondary[] = {"back"};
TrainlogDatabase *database = NULL;
TrainlogDashboardSnapshot dashboard;
TrainlogSetInput rep_sets[] = {
{10, 0, true, 40.0}, {8, 0, true, 1000.0}
};
TrainlogSetInput repeated_set = {12, 0, true, 50.0};
TrainlogSetInput duration_set = {0, 60, true, 30.0};
TrainlogSetInput assistance_set = {10, 0, true, 5.0};
TrainlogSetInput unweighted_set = {10, 0, false, 0.0};
TrainlogSessionExerciseInput occurrences[4];
TrainlogSessionInput session;
bool chest = false;
bool back = false;
bool unclassified = false;
size_t working = 0U;
size_t index;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000000", "MAX égalité", "max egalite",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Répétitions",
"repetitions", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000001", "MAX secondaire",
"max secondaire",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000002", "Durée séries", "duree series",
TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000003", "Continu", "continu",
TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000004", "Assistance", "assistance",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000005", "Poids absent antérieur",
"poids absent anterieur", TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, (TrainlogExerciseDataFields)0) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database,
"ex_33333333-3333-4333-8333-333333333333", "chest",
primary_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database,
"ex_10000000-0000-4000-8000-000000000002", "back", NULL, 0U) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database,
"ex_10000000-0000-4000-8000-000000000004", "chest", NULL, 0U) ==
TRAINLOG_STATUS_OK);
(void)memset(occurrences, 0, sizeof(occurrences));
(void)memset(&session, 0, sizeof(session));
for (index = 0U; index < 2U; ++index) {
(void)snprintf(occurrences[index].exercise_id,
sizeof(occurrences[index].exercise_id), "%s",
"ex_33333333-3333-4333-8333-333333333333");
(void)snprintf(occurrences[index].equipment_id,
sizeof(occurrences[index].equipment_id), "%s", "leg_press");
occurrences[index].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[index].tracking_mode = TRAINLOG_TRACKING_REPS;
occurrences[index].load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrences[index].target_sets = 1;
occurrences[index].target_reps = 10;
occurrences[index].target_has_weight = true;
occurrences[index].target_weight_kg = 40.0;
}
(void)snprintf(occurrences[0].entry_id, sizeof(occurrences[0].entry_id), "%s",
"sxe_10000000-0000-4000-8000-000000000001");
occurrences[0].sets = rep_sets;
occurrences[0].set_count = 2U;
(void)snprintf(occurrences[1].entry_id, sizeof(occurrences[1].entry_id), "%s",
"sxe_10000000-0000-4000-8000-000000000002");
occurrences[1].sets = &repeated_set;
occurrences[1].set_count = 1U;
(void)snprintf(occurrences[2].exercise_id, sizeof(occurrences[2].exercise_id),
"%s", "ex_10000000-0000-4000-8000-000000000002");
(void)snprintf(occurrences[2].equipment_id, sizeof(occurrences[2].equipment_id),
"%s", "leg_press");
occurrences[2].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[2].tracking_mode = TRAINLOG_TRACKING_DURATION;
occurrences[2].load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrences[2].target_sets = 1;
occurrences[2].target_duration_seconds = 60;
occurrences[2].target_has_weight = true;
occurrences[2].target_weight_kg = 30.0;
occurrences[2].sets = &duration_set;
occurrences[2].set_count = 1U;
(void)snprintf(occurrences[3].exercise_id, sizeof(occurrences[3].exercise_id),
"%s", "ex_10000000-0000-4000-8000-000000000003");
occurrences[3].recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
occurrences[3].tracking_mode = TRAINLOG_TRACKING_DURATION;
occurrences[3].load_mode = TRAINLOG_LOAD_NONE;
occurrences[3].continuous_duration_seconds = 600;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_32000000-0000-4000-8000-000000000001");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-10T08:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = occurrences;
session.exercise_count = 4U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_optional_weight_set(database,
"se_32000000-0000-4000-8000-000000000009",
"ex_10000000-0000-4000-8000-000000000005", "2026-09-08T08:00:00Z",
false, 0.0));
CHECK(dashboard_insert_optional_weight_set(database,
"se_32000000-0000-4000-8000-000000000010",
"ex_10000000-0000-4000-8000-000000000005", "2026-09-09T08:00:00Z",
true, 25.0));
(void)memset(&occurrences[0], 0, sizeof(occurrences[0]));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id),
"%s", "ex_10000000-0000-4000-8000-000000000004");
(void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id),
"%s", "assisted_dip_chin_machine");
occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS;
occurrences[0].load_mode = TRAINLOG_LOAD_ASSISTANCE;
occurrences[0].target_sets = 1;
occurrences[0].target_reps = 10;
occurrences[0].target_has_weight = true;
occurrences[0].target_weight_kg = 5.0;
occurrences[0].sets = &assistance_set;
occurrences[0].set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_32000000-0000-4000-8000-000000000002");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-11T08:00:00Z");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
"2026-09-11T09:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = occurrences;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_plan_for_exercise(database,
"se_32000000-0000-4000-8000-000000000003",
"ex_10000000-0000-4000-8000-000000000001", "2026-09-12T08:00:00Z"));
(void)memset(&occurrences[0], 0, sizeof(occurrences[0]));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id),
"%s", "ex_33333333-3333-4333-8333-333333333333");
(void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id),
"%s", "leg_press");
occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS;
occurrences[0].load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrences[0].target_sets = 1;
occurrences[0].target_reps = 10;
occurrences[0].target_has_weight = true;
occurrences[0].target_weight_kg = 40.0;
occurrences[0].sets = &unweighted_set;
occurrences[0].set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_32000000-0000-4000-8000-000000000008");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-12T12:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = occurrences;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_completed_set_dose(database,
"se_32000000-0000-4000-8000-000000000004", "2026-09-13T08:00:00Z",
"", TRAINLOG_LOAD_EXTERNAL, "leg_press", 45.0, 10));
CHECK(dashboard_insert_completed_set_dose(database,
"se_32000000-0000-4000-8000-000000000005", "2026-09-13T08:00:00Z",
"", TRAINLOG_LOAD_EXTERNAL, "leg_press", 50.0, 10));
CHECK(dashboard_insert_max_for_exercise(database,
"se_32000000-0000-4000-8000-000000000006",
"sxe_32000000-0000-4000-8000-000000000006",
"ex_10000000-0000-4000-8000-000000000001", "2026-09-14T08:00:00Z", 100.0));
CHECK(dashboard_insert_max_for_exercise(database,
"se_32000000-0000-4000-8000-000000000007",
"sxe_32000000-0000-4000-8000-000000000007",
"ex_10000000-0000-4000-8000-000000000000", "2026-09-14T08:00:00Z", 200.0));
(void)memset(&dashboard, 0, sizeof(dashboard));
dashboard.period = TRAINLOG_DASHBOARD_30_DAYS;
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && !dashboard.partial &&
!dashboard.invalid_data);
CHECK(dashboard.completed_session_count == 9U);
CHECK(dashboard.performed_set_count == 10U);
CHECK(dashboard.explicit_max_count == 2U);
CHECK(dashboard.distinct_exercise_count == 7U);
CHECK(dashboard.has_performance);
CHECK(strcmp(dashboard.exercise.exercise_id,
"ex_33333333-3333-4333-8333-333333333333") == 0);
CHECK(dashboard.performance_tracking_mode == TRAINLOG_TRACKING_REPS &&
dashboard.performance_dose == 10);
CHECK(dashboard.performance_count == 4U);
CHECK(dashboard.performance[0].weight_kg == 40.0 &&
dashboard.performance[1].has_weight == 1 &&
dashboard.performance[1].weight_kg == 0.0 &&
dashboard.performance[2].weight_kg == 45.0 &&
dashboard.performance[3].weight_kg == 50.0);
CHECK(dashboard.has_explicit_max && dashboard.max_weight_kg == 200.0);
CHECK(strcmp(dashboard.max_exercise.exercise_id,
"ex_10000000-0000-4000-8000-000000000000") == 0);
for (index = 0U; index < dashboard.week_count; ++index)
working += dashboard.weeks[index].working_improvements;
/* Existing behavior: a matching earlier unweighted set supplies a 0.0
* baseline and therefore makes the later weighted set an improvement. */
CHECK(working == 3U);
CHECK(dashboard.zone_count == 4U);
for (index = 0U; index < dashboard.zone_count; ++index) {
chest = chest || (strcmp(dashboard.zones[index].zone_id, "chest") == 0 &&
dashboard.zones[index].count == 3U);
back = back || (strcmp(dashboard.zones[index].zone_id, "back") == 0 &&
dashboard.zones[index].count == 2U);
unclassified = unclassified ||
(dashboard.zones[index].zone_id[0] == '\0' &&
dashboard.zones[index].count == 6U);
}
CHECK(chest && back && unclassified);
trainlog_database_close(database);
return true;
}
static bool test_dashboard_characterization_session_and_fact_bounds(void)
{
TrainlogDatabase *database = NULL;
TrainlogDashboardSnapshot dashboard;
TrainlogSetInput *sets = NULL;
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
size_t index;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Bornes", "bornes",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
for (index = 0U; index < TRAINLOG_DASHBOARD_SESSION_CAPACITY; ++index) {
char session_id[TRAINLOG_ID_MAX + 1U];
(void)snprintf(session_id, sizeof(session_id),
"se_33000000-0000-4000-8000-%012zu", index);
CHECK(dashboard_insert_completed_set(database, session_id,
"2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0));
}
(void)memset(&dashboard, 0, sizeof(dashboard));
dashboard.period = TRAINLOG_DASHBOARD_ALL;
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && dashboard.partial);
CHECK(dashboard.completed_session_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY);
CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY);
CHECK(dashboard_insert_completed_set(database,
"se_33000000-0000-4000-8000-000000000128",
"2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0));
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && dashboard.partial);
CHECK(dashboard.completed_session_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY);
CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY + 1U);
trainlog_database_close(database);
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Faits bornés",
"faits bornes", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
sets = calloc(TRAINLOG_DASHBOARD_FACT_CAPACITY, sizeof(*sets));
CHECK(sets != NULL);
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
"ex_33333333-3333-4333-8333-333333333333");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_NONE;
occurrence.target_sets = 1;
occurrence.target_reps = 1;
occurrence.sets = sets;
occurrence.set_count = TRAINLOG_DASHBOARD_FACT_CAPACITY;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_34000000-0000-4000-8000-000000000001");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-10T08:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
free(sets);
sets = NULL;
(void)memset(&dashboard, 0, sizeof(dashboard));
dashboard.period = TRAINLOG_DASHBOARD_ALL;
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && !dashboard.partial);
CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_FACT_CAPACITY);
/* A second honest persisted occurrence crosses the fact capacity without
* bypassing the public persistence contract. */
CHECK(dashboard_insert_completed_set(database,
"se_34000000-0000-4000-8000-000000000002",
"2026-09-10T08:00:01Z", "", TRAINLOG_LOAD_NONE, "", 1.0));
CHECK(dashboard_load(database, dashboard.period, &dashboard));
CHECK(!dashboard.error && dashboard.partial);
CHECK(dashboard.performed_set_count == TRAINLOG_DASHBOARD_FACT_CAPACITY);
trainlog_database_close(database);
return true;
}
int main(void)
{
if (!test_dashboard_characterization_empty_and_window_boundaries() ||
!test_dashboard_characterization_modalities_ties_and_zones() ||
!test_dashboard_characterization_session_and_fact_bounds()) return 1;
return 0;
}

View file

@ -0,0 +1,9 @@
/* Standalone public-header compilation contract. */
#include "trainlog/dashboard.h"
int main(void)
{
TrainlogDashboardQuery query = {TRAINLOG_DASHBOARD_30_DAYS, 0};
TrainlogDashboardSnapshot snapshot = {0};
return query.reference_unix_second == 0 && !snapshot.error ? 0 : 1;
}

View file

@ -48,7 +48,7 @@ FR = re.compile(r'[À-ÿ]|\b(?:aucun|aucune|abandonner|annuler|choisir|durée|en
# non-copy token: punctuation/layout, a protocol token, or an internal key.
EXCLUDED = {
"tui/src/tui.c": {
1083:"—",1495:"— kg",3990:"J−%zu",3993:"M−%zu",3996:"P−%zu",4779:"msg.zone.required",5224:"statistics.zone.previous",5226:"statistics.zone.next",5290:"generator.warning.zone",5632:"—",5675:"— ",5680:"— ",5698:"— ",5713:"—",5716:"—",5840:"—",5843:"—",5846:"—",5901:"—",6332:"%-22s —",6632:"▶",6635:"▶",6663:"▶",6672:"▶",6685:"▶",6688:"▶",6712:"▶",6717:"▶",6908:"%02d/%02d … %02d/%02d",6950:"— ",7060:"PC↔Android",7106:"▶",7330:"%s · %s",7338:"—",7339:"—",7415:"—",7418:"—",7587:"—",7615:"—",7617:"—",7619:"—",7737:"aucun",7739:"aucun",7904:"—",7955:"—",8085:" · ↑",8086:" · ↓"},
1083:"—",1495:"— kg",3990:"J−%zu",3993:"M−%zu",3996:"P−%zu",4051:"msg.zone.required",4496:"statistics.zone.previous",4498:"statistics.zone.next",4562:"generator.warning.zone",5632:"—",5675:"— ",5680:"— ",5698:"— ",5713:"—",5716:"—",5840:"—",5843:"—",5846:"—",5901:"—",6332:"%-22s —",6632:"▶",6635:"▶",6663:"▶",6672:"▶",6685:"▶",6688:"▶",6712:"▶",6717:"▶",6908:"%02d/%02d … %02d/%02d",6950:"— ",7060:"PC↔Android",7106:"▶",7330:"%s · %s",7338:"—",7339:"—",7415:"—",7418:"—",7587:"—",7615:"—",7617:"—",7619:"—",7014:"aucun",7016:"aucun",7904:"—",7955:"—",8085:" · ↑",8086:" · ↓"},
"tui/src/chart.c": {254:"◆ %.2f %s"},
"tui/src/app_shell.c": {317:"…"},
"tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"},

View file

@ -5,11 +5,13 @@
#include <errno.h>
#include <stdarg.h>
#include <sqlite3.h>
#include "../src/database_internal.h"
/* The workflows remain file-private in production. Including their translation
* unit lets this test drive the exact code paths with the terminal port mocked,
* without adding a public test API to Trainlog. */
#define TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST 1
#include "../src/tui.c"
struct TrainlogTerminal {
@ -1437,499 +1439,21 @@ static bool dashboard_utc_timestamp_at(time_t value, char output[TRAINLOG_TIMEST
TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
static bool dashboard_characterization_fix_clock(const char *timestamp)
/* Presentation-only fixture. Bucket construction itself is covered through
* trainlog_dashboard_load() in test_dashboard.c. */
static void dashboard_prepare_render_bucket_fixture(
TrainlogDashboardSnapshot *dashboard)
{
TrainlogTimestampKey key;
int64_t unix_second;
time_t converted;
if (!trainlog_timestamp_parse(timestamp, strlen(timestamp), &key)) return false;
unix_second = key.utc_second - INT64_C(719162) * INT64_C(86400);
converted = (time_t)unix_second;
if ((int64_t)converted != unix_second) return false;
dashboard_characterization_clock_value = converted;
dashboard_characterization_clock_enabled = true;
return true;
}
static void dashboard_characterization_restore_clock(void)
{
dashboard_characterization_clock_enabled = false;
}
static bool dashboard_insert_max_for_exercise(TrainlogDatabase *database,
const char *session_id, const char *entry_id, const char *exercise_id,
const char *started_at, double weight)
{
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id);
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
exercise_id);
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
"leg_press");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_NONE;
occurrence.has_max_weight = true;
occurrence.max_weight_kg = weight;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
session.session_type = TRAINLOG_SESSION_MAX_TEST;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool dashboard_insert_plan_for_exercise(TrainlogDatabase *database,
const char *session_id, const char *exercise_id, const char *started_at)
{
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
exercise_id);
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
"leg_press");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrence.target_sets = 3;
occurrence.target_reps = 10;
occurrence.target_has_weight = true;
occurrence.target_weight_kg = 50.0;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool dashboard_insert_optional_weight_set(TrainlogDatabase *database,
const char *session_id, const char *exercise_id, const char *started_at,
bool has_weight, double weight)
{
TrainlogSetInput set = {10, 0, has_weight, weight};
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
exercise_id);
(void)snprintf(occurrence.equipment_id, sizeof(occurrence.equipment_id), "%s",
"leg_press");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrence.target_sets = 1;
occurrence.target_reps = 10;
occurrence.target_has_weight = true;
occurrence.target_weight_kg = 25.0;
occurrence.sets = &set;
occurrence.set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
}
static bool test_dashboard_characterization_empty_and_window_boundaries(void)
{
TrainlogDatabase *database = NULL;
TrainlogAppContext app;
size_t index;
CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z"));
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_30_DAYS;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && !app.dashboard.partial &&
!app.dashboard.invalid_data);
CHECK(!app.dashboard.has_performance && !app.dashboard.has_explicit_max &&
!app.dashboard.has_body);
CHECK(app.dashboard.performance_count == 0U && app.dashboard.body_count == 0U);
CHECK(app.dashboard.completed_session_count == 0U &&
app.dashboard.performed_set_count == 0U &&
app.dashboard.distinct_exercise_count == 0U &&
app.dashboard.explicit_max_count == 0U);
/* The database is empty of training history, not of the seeded canonical
* catalogue: zone distribution is therefore deliberately non-empty. */
CHECK(app.dashboard.zone_count == 4U);
CHECK(strcmp(app.dashboard.zones[0].label, "Non classés") == 0 &&
app.dashboard.zones[0].count == 2U);
CHECK(app.dashboard.week_count == 6U);
for (index = 0U; index < app.dashboard.week_count; ++index)
CHECK(app.dashboard.weeks[index].sessions == 0U &&
app.dashboard.weeks[index].maxima == 0U &&
app.dashboard.weeks[index].working_improvements == 0U &&
app.dashboard.weeks[index].max_improvements == 0U);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Frontière",
"frontiere", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_completed_set(database,
"se_31000000-0000-4000-8000-000000000001",
"2026-08-17T11:59:59Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 10.0));
CHECK(dashboard_insert_completed_set(database,
"se_31000000-0000-4000-8000-000000000002",
"2026-08-17T12:00:00Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0));
CHECK(dashboard_insert_completed_set(database,
"se_31000000-0000-4000-8000-000000000003",
"2026-08-17T12:00:01Z", "", TRAINLOG_LOAD_EXTERNAL, "leg_press", 30.0));
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && !app.dashboard.invalid_data);
CHECK(app.dashboard.completed_session_count == 2U);
CHECK(app.dashboard.performed_set_count == 2U);
CHECK(app.dashboard.distinct_exercise_count == 1U);
CHECK(app.dashboard.has_performance && app.dashboard.performance_count == 2U);
CHECK(strcmp(app.dashboard.performance[0].started_at,
"2026-08-17T12:00:00Z") == 0);
CHECK(strcmp(app.dashboard.performance[1].started_at,
"2026-08-17T12:00:01Z") == 0);
trainlog_database_close(database);
dashboard_characterization_restore_clock();
return true;
(void)memset(dashboard->weeks, 0, sizeof(dashboard->weeks));
dashboard->period = TRAINLOG_DASHBOARD_30_DAYS;
dashboard->week_count = 6U;
for (index = 0U; index < dashboard->week_count; ++index) {
dashboard->weeks[index].start_day = 971 + (int64_t)index * 5;
dashboard->weeks[index].span_days = 5;
}
}
static bool test_dashboard_characterization_modalities_ties_and_zones(void)
{
static const char *const primary_secondary[] = {"back"};
TrainlogDatabase *database = NULL;
TrainlogAppContext app;
TrainlogSetInput rep_sets[] = {
{10, 0, true, 40.0}, {8, 0, true, 1000.0}
};
TrainlogSetInput repeated_set = {12, 0, true, 50.0};
TrainlogSetInput duration_set = {0, 60, true, 30.0};
TrainlogSetInput assistance_set = {10, 0, true, 5.0};
TrainlogSetInput unweighted_set = {10, 0, false, 0.0};
TrainlogSessionExerciseInput occurrences[4];
TrainlogSessionInput session;
bool chest = false;
bool back = false;
bool unclassified = false;
size_t working = 0U;
size_t index;
CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z"));
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000000", "MAX égalité", "max egalite",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Répétitions",
"repetitions", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000001", "MAX secondaire",
"max secondaire",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000002", "Durée séries", "duree series",
TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000003", "Continu", "continu",
TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_CONTINUOUS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000004", "Assistance", "assistance",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_10000000-0000-4000-8000-000000000005", "Poids absent antérieur",
"poids absent anterieur", TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS, (TrainlogExerciseDataFields)0) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database,
"ex_33333333-3333-4333-8333-333333333333", "chest",
primary_secondary, 1U) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database,
"ex_10000000-0000-4000-8000-000000000002", "back", NULL, 0U) ==
TRAINLOG_STATUS_OK);
CHECK(trainlog_database_replace_exercise_body_zones(database,
"ex_10000000-0000-4000-8000-000000000004", "chest", NULL, 0U) ==
TRAINLOG_STATUS_OK);
(void)memset(occurrences, 0, sizeof(occurrences));
(void)memset(&session, 0, sizeof(session));
for (index = 0U; index < 2U; ++index) {
(void)snprintf(occurrences[index].exercise_id,
sizeof(occurrences[index].exercise_id), "%s",
"ex_33333333-3333-4333-8333-333333333333");
(void)snprintf(occurrences[index].equipment_id,
sizeof(occurrences[index].equipment_id), "%s", "leg_press");
occurrences[index].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[index].tracking_mode = TRAINLOG_TRACKING_REPS;
occurrences[index].load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrences[index].target_sets = 1;
occurrences[index].target_reps = 10;
occurrences[index].target_has_weight = true;
occurrences[index].target_weight_kg = 40.0;
}
(void)snprintf(occurrences[0].entry_id, sizeof(occurrences[0].entry_id), "%s",
"sxe_10000000-0000-4000-8000-000000000001");
occurrences[0].sets = rep_sets;
occurrences[0].set_count = 2U;
(void)snprintf(occurrences[1].entry_id, sizeof(occurrences[1].entry_id), "%s",
"sxe_10000000-0000-4000-8000-000000000002");
occurrences[1].sets = &repeated_set;
occurrences[1].set_count = 1U;
(void)snprintf(occurrences[2].exercise_id, sizeof(occurrences[2].exercise_id),
"%s", "ex_10000000-0000-4000-8000-000000000002");
(void)snprintf(occurrences[2].equipment_id, sizeof(occurrences[2].equipment_id),
"%s", "leg_press");
occurrences[2].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[2].tracking_mode = TRAINLOG_TRACKING_DURATION;
occurrences[2].load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrences[2].target_sets = 1;
occurrences[2].target_duration_seconds = 60;
occurrences[2].target_has_weight = true;
occurrences[2].target_weight_kg = 30.0;
occurrences[2].sets = &duration_set;
occurrences[2].set_count = 1U;
(void)snprintf(occurrences[3].exercise_id, sizeof(occurrences[3].exercise_id),
"%s", "ex_10000000-0000-4000-8000-000000000003");
occurrences[3].recording_mode = TRAINLOG_RECORDING_CONTINUOUS;
occurrences[3].tracking_mode = TRAINLOG_TRACKING_DURATION;
occurrences[3].load_mode = TRAINLOG_LOAD_NONE;
occurrences[3].continuous_duration_seconds = 600;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_32000000-0000-4000-8000-000000000001");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-10T08:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = occurrences;
session.exercise_count = 4U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_optional_weight_set(database,
"se_32000000-0000-4000-8000-000000000009",
"ex_10000000-0000-4000-8000-000000000005", "2026-09-08T08:00:00Z",
false, 0.0));
CHECK(dashboard_insert_optional_weight_set(database,
"se_32000000-0000-4000-8000-000000000010",
"ex_10000000-0000-4000-8000-000000000005", "2026-09-09T08:00:00Z",
true, 25.0));
(void)memset(&occurrences[0], 0, sizeof(occurrences[0]));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id),
"%s", "ex_10000000-0000-4000-8000-000000000004");
(void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id),
"%s", "assisted_dip_chin_machine");
occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS;
occurrences[0].load_mode = TRAINLOG_LOAD_ASSISTANCE;
occurrences[0].target_sets = 1;
occurrences[0].target_reps = 10;
occurrences[0].target_has_weight = true;
occurrences[0].target_weight_kg = 5.0;
occurrences[0].sets = &assistance_set;
occurrences[0].set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_32000000-0000-4000-8000-000000000002");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-11T08:00:00Z");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s",
"2026-09-11T09:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = occurrences;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_plan_for_exercise(database,
"se_32000000-0000-4000-8000-000000000003",
"ex_10000000-0000-4000-8000-000000000001", "2026-09-12T08:00:00Z"));
(void)memset(&occurrences[0], 0, sizeof(occurrences[0]));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrences[0].exercise_id, sizeof(occurrences[0].exercise_id),
"%s", "ex_33333333-3333-4333-8333-333333333333");
(void)snprintf(occurrences[0].equipment_id, sizeof(occurrences[0].equipment_id),
"%s", "leg_press");
occurrences[0].recording_mode = TRAINLOG_RECORDING_SETS;
occurrences[0].tracking_mode = TRAINLOG_TRACKING_REPS;
occurrences[0].load_mode = TRAINLOG_LOAD_EXTERNAL;
occurrences[0].target_sets = 1;
occurrences[0].target_reps = 10;
occurrences[0].target_has_weight = true;
occurrences[0].target_weight_kg = 40.0;
occurrences[0].sets = &unweighted_set;
occurrences[0].set_count = 1U;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_32000000-0000-4000-8000-000000000008");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-12T12:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = occurrences;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
CHECK(dashboard_insert_completed_set_dose(database,
"se_32000000-0000-4000-8000-000000000004", "2026-09-13T08:00:00Z",
"", TRAINLOG_LOAD_EXTERNAL, "leg_press", 45.0, 10));
CHECK(dashboard_insert_completed_set_dose(database,
"se_32000000-0000-4000-8000-000000000005", "2026-09-13T08:00:00Z",
"", TRAINLOG_LOAD_EXTERNAL, "leg_press", 50.0, 10));
CHECK(dashboard_insert_max_for_exercise(database,
"se_32000000-0000-4000-8000-000000000006",
"sxe_32000000-0000-4000-8000-000000000006",
"ex_10000000-0000-4000-8000-000000000001", "2026-09-14T08:00:00Z", 100.0));
CHECK(dashboard_insert_max_for_exercise(database,
"se_32000000-0000-4000-8000-000000000007",
"sxe_32000000-0000-4000-8000-000000000007",
"ex_10000000-0000-4000-8000-000000000000", "2026-09-14T08:00:00Z", 200.0));
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_30_DAYS;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && !app.dashboard.partial &&
!app.dashboard.invalid_data);
CHECK(app.dashboard.completed_session_count == 9U);
CHECK(app.dashboard.performed_set_count == 10U);
CHECK(app.dashboard.explicit_max_count == 2U);
CHECK(app.dashboard.distinct_exercise_count == 7U);
CHECK(app.dashboard.has_performance);
CHECK(strcmp(app.dashboard.exercise.exercise_id,
"ex_33333333-3333-4333-8333-333333333333") == 0);
CHECK(app.dashboard.performance_tracking_mode == TRAINLOG_TRACKING_REPS &&
app.dashboard.performance_dose == 10);
CHECK(app.dashboard.performance_count == 4U);
CHECK(app.dashboard.performance[0].weight_kg == 40.0 &&
app.dashboard.performance[1].has_weight == 1 &&
app.dashboard.performance[1].weight_kg == 0.0 &&
app.dashboard.performance[2].weight_kg == 45.0 &&
app.dashboard.performance[3].weight_kg == 50.0);
CHECK(app.dashboard.has_explicit_max && app.dashboard.max_weight_kg == 200.0);
CHECK(strcmp(app.dashboard.max_exercise.exercise_id,
"ex_10000000-0000-4000-8000-000000000000") == 0);
for (index = 0U; index < app.dashboard.week_count; ++index)
working += app.dashboard.weeks[index].working_improvements;
/* Existing behavior: a matching earlier unweighted set supplies a 0.0
* baseline and therefore makes the later weighted set an improvement. */
CHECK(working == 3U);
CHECK(app.dashboard.zone_count == 4U);
for (index = 0U; index < app.dashboard.zone_count; ++index) {
chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 &&
app.dashboard.zones[index].count == 3U);
back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 &&
app.dashboard.zones[index].count == 2U);
unclassified = unclassified ||
(strcmp(app.dashboard.zones[index].label, "Non classés") == 0 &&
app.dashboard.zones[index].count == 6U);
}
CHECK(chest && back && unclassified);
{
TrainlogExercisePerformancePoint left;
TrainlogExercisePerformancePoint right;
(void)memset(&left, 0, sizeof(left));
(void)memset(&right, 0, sizeof(right));
(void)snprintf(left.started_at, sizeof(left.started_at), "%s",
"2026-09-10T08:00:00Z");
(void)snprintf(right.started_at, sizeof(right.started_at), "%s",
left.started_at);
(void)snprintf(left.session_id, sizeof(left.session_id), "%s",
"se_32000000-0000-4000-8000-000000000011");
(void)snprintf(right.session_id, sizeof(right.session_id), "%s",
left.session_id);
left.weight_kg = 10.0;
right.weight_kg = 20.0;
CHECK(dashboard_performance_time_compare(&left, &right) == 0);
}
trainlog_database_close(database);
dashboard_characterization_restore_clock();
return true;
}
static bool test_dashboard_characterization_session_and_fact_bounds(void)
{
TrainlogDatabase *database = NULL;
TrainlogAppContext app;
TrainlogSetInput *sets = NULL;
TrainlogSessionExerciseInput occurrence;
TrainlogSessionInput session;
size_t index;
CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z"));
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Bornes", "bornes",
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
for (index = 0U; index < MAX_SESSIONS; ++index) {
char session_id[TRAINLOG_ID_MAX + 1U];
(void)snprintf(session_id, sizeof(session_id),
"se_33000000-0000-4000-8000-%012zu", index);
CHECK(dashboard_insert_completed_set(database, session_id,
"2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0));
}
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && app.dashboard.partial);
CHECK(app.dashboard.completed_session_count == MAX_SESSIONS);
CHECK(app.dashboard.performed_set_count == MAX_SESSIONS);
CHECK(dashboard_insert_completed_set(database,
"se_33000000-0000-4000-8000-000000000128",
"2026-09-10T08:00:00Z", "", TRAINLOG_LOAD_NONE, "", 1.0));
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && app.dashboard.partial);
CHECK(app.dashboard.completed_session_count == MAX_SESSIONS);
CHECK(app.dashboard.performed_set_count == MAX_SESSIONS + 1U);
trainlog_database_close(database);
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_33333333-3333-4333-8333-333333333333", "Faits bornés",
"faits bornes", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
(TrainlogExerciseDataFields)0) == TRAINLOG_STATUS_OK);
sets = calloc(DASHBOARD_FACT_CAPACITY, sizeof(*sets));
CHECK(sets != NULL);
(void)memset(&occurrence, 0, sizeof(occurrence));
(void)memset(&session, 0, sizeof(session));
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s",
"ex_33333333-3333-4333-8333-333333333333");
occurrence.recording_mode = TRAINLOG_RECORDING_SETS;
occurrence.tracking_mode = TRAINLOG_TRACKING_REPS;
occurrence.load_mode = TRAINLOG_LOAD_NONE;
occurrence.target_sets = 1;
occurrence.target_reps = 1;
occurrence.sets = sets;
occurrence.set_count = DASHBOARD_FACT_CAPACITY;
(void)snprintf(session.session_id, sizeof(session.session_id), "%s",
"se_34000000-0000-4000-8000-000000000001");
(void)snprintf(session.started_at, sizeof(session.started_at), "%s",
"2026-09-10T08:00:00Z");
session.session_type = TRAINLOG_SESSION_TRAINING;
session.exercises = &occurrence;
session.exercise_count = 1U;
CHECK(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK);
free(sets);
sets = NULL;
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && !app.dashboard.partial);
CHECK(app.dashboard.performed_set_count == DASHBOARD_FACT_CAPACITY);
CHECK(sqlite3_exec(database->connection,
"INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) "
"SELECT id,4096,0,NULL FROM session_exercises WHERE "
"entry_id=(SELECT entry_id FROM session_exercises LIMIT 1);",
NULL, NULL, NULL) == SQLITE_OK);
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && app.dashboard.partial);
CHECK(app.dashboard.performed_set_count == DASHBOARD_FACT_CAPACITY);
trainlog_database_close(database);
dashboard_characterization_restore_clock();
return true;
}
static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void)
{
@ -1946,38 +1470,12 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void)
app.dashboard.performed_set_count = 9U;
app.dashboard.distinct_exercise_count = 3U;
app.dashboard.explicit_max_count = 1U;
dashboard_prepare_period_buckets(&app.dashboard, 1000, 971);
dashboard_prepare_render_bucket_fixture(&app.dashboard);
app.dashboard.weeks[5].sessions = 4U;
app.dashboard.weeks[5].maxima = 1U;
app.dashboard.weeks[5].working_improvements = 2U;
app.dashboard.weeks[5].max_improvements = 1U;
/* CONTRACT: the selector changes the shared projection used by progress
* and frequency; it is not a label over an invariant six-week chart. */
{
static const TrainlogStatisticsPeriod periods[] = {
TRAINLOG_STATS_7_DAYS, TRAINLOG_STATS_30_DAYS,
TRAINLOG_STATS_90_DAYS, TRAINLOG_STATS_YEAR, TRAINLOG_STATS_ALL
};
static const size_t counts[] = {7U, 6U, 6U, 12U, 12U};
static const int64_t spans[] = {1, 5, 15, 31, 84};
size_t period_index;
for (period_index = 0U; period_index < 5U; ++period_index) {
app.dashboard.period = periods[period_index];
dashboard_prepare_period_buckets(&app.dashboard, 1000, 0);
CHECK(app.dashboard.week_count == counts[period_index]);
CHECK(app.dashboard.weeks[0].span_days == spans[period_index]);
CHECK(strcmp(app.dashboard.weeks[
app.dashboard.week_count - 1U].label, "actuel") == 0);
}
app.dashboard.period = TRAINLOG_STATS_30_DAYS;
dashboard_prepare_period_buckets(&app.dashboard, 1000, 971);
app.dashboard.weeks[5].sessions = 4U;
app.dashboard.weeks[5].maxima = 1U;
app.dashboard.weeks[5].working_improvements = 2U;
app.dashboard.weeks[5].max_improvements = 1U;
}
/* A lone measurement is text-only; event blocks are factual period-bucket
* counts and the accessible totals expose their meaning. */
dashboard_prepare_render(&app, &terminal, &surface, 120, 35, 96, 29);
@ -1994,7 +1492,7 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void)
/* Frequency geometry has one shared x coordinate per period bucket.
* The end buckets also prove a wide chart uses its useful span. */
(void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks));
dashboard_prepare_period_buckets(&app.dashboard, 1000, 971);
dashboard_prepare_render_bucket_fixture(&app.dashboard);
for (index = 0U; index < 6U; ++index) {
app.dashboard.weeks[index].sessions = 11U + index;
app.dashboard.weeks[index].maxima = 1U;
@ -2003,7 +1501,9 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void)
app_shell_dashboard_frequency_chart(&app, &app.dashboard, 11, 4, 88, 7);
for (index = 0U; index < 6U; ++index) {
char count[8];
const char *label = app.dashboard.weeks[index].label;
char label[12];
dashboard_period_bucket_label(app.dashboard.period, index,
app.dashboard.week_count, label);
int label_column;
int x;
(void)snprintf(count, sizeof(count), "%zu", 11U + index);
@ -2066,7 +1566,7 @@ static bool test_stats_dashboard_sparse_series_names_frequency_and_footer(void)
/* No classified performance events still occupies the selected-period
* timeline, with dots and an explicit explanation. */
(void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks));
dashboard_prepare_period_buckets(&app.dashboard, 1000, 971);
dashboard_prepare_render_bucket_fixture(&app.dashboard);
app.dashboard.body_count = 1U;
dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16);
app_shell_render_dashboard(&app);
@ -2313,11 +1813,11 @@ static bool test_stats_dashboard_uses_represented_local_weeks(void)
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error);
CHECK(app.dashboard.week_count >= 1U &&
app.dashboard.week_count <= DASHBOARD_BUCKET_COUNT);
app.dashboard.week_count <= TRAINLOG_DASHBOARD_BUCKET_CAPACITY);
/* Tout projects the complete represented history instead of silently
* retaining the former six-week viewport. */
{
@ -2354,11 +1854,11 @@ static bool test_stats_dashboard_current_week_remains_zero_after_past_activity(v
TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0));
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error);
CHECK(app.dashboard.week_count >= 1U &&
app.dashboard.week_count <= DASHBOARD_BUCKET_COUNT);
app.dashboard.week_count <= TRAINLOG_DASHBOARD_BUCKET_CAPACITY);
CHECK(app.dashboard.weeks[app.dashboard.week_count - 1U].sessions == 0U);
dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20);
app_shell_dashboard_frequency_chart(&app, &app.dashboard, 3, 4, 64, 3);
@ -2435,7 +1935,7 @@ static bool test_stats_dashboard_exact_dose_strict_later_and_invalid_rows(void)
(void)memset(&app, 0, sizeof(app));
app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && app.dashboard.invalid_data);
CHECK(app.dashboard.completed_session_count == 7U);
@ -2611,16 +2111,16 @@ static bool test_stats_detail_charts_and_global_zone_projection(void)
"ex_10000000-0000-4000-8000-000000000001",
"ex_10000000-0000-4000-8000-000000000002") == TRAINLOG_STATUS_OK);
(void)memset(&app, 0, sizeof(app)); app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && app.dashboard.zone_count == 4U);
for (index = 0U; index < app.dashboard.zone_count; ++index) {
chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 &&
chest = chest || (strcmp(app.dashboard.zones[index].zone_id, "chest") == 0 &&
app.dashboard.zones[index].count == 2U);
back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 &&
back = back || (strcmp(app.dashboard.zones[index].zone_id, "back") == 0 &&
app.dashboard.zones[index].count == 2U);
unclassified = unclassified ||
(strcmp(app.dashboard.zones[index].label, "Non classés") == 0 &&
(app.dashboard.zones[index].zone_id[0] == '\0' &&
app.dashboard.zones[index].count == 3U);
}
CHECK(chest && back && unclassified);
@ -2640,8 +2140,8 @@ static bool test_stats_detail_charts_and_global_zone_projection(void)
size_t geometry;
app.dashboard.zone_count = sizeof(counts) / sizeof(counts[0]);
for (index = 0U; index < app.dashboard.zone_count; ++index) {
(void)snprintf(app.dashboard.zones[index].label,
sizeof(app.dashboard.zones[index].label), "Zone %zu", index + 1U);
(void)snprintf(app.dashboard.zones[index].zone_id,
sizeof(app.dashboard.zones[index].zone_id), "Zone %zu", index + 1U);
app.dashboard.zones[index].count = counts[index];
}
for (geometry = 0U; geometry < 4U; ++geometry) {
@ -2986,9 +2486,6 @@ int main(void)
!test_shell_sync_history_neutralizes_legacy_directions() ||
!test_equipment_collector_finds_match_after_first_pages() ||
!test_exercise_merge_action_search_confirm_selects_target() ||
!test_dashboard_characterization_empty_and_window_boundaries() ||
!test_dashboard_characterization_modalities_ties_and_zones() ||
!test_dashboard_characterization_session_and_fact_bounds() ||
!test_stats_dashboard_sparse_series_names_frequency_and_footer() ||
!test_stats_dashboard_uses_real_exact_time_snapshots_responsively() ||
!test_stats_dashboard_uses_represented_local_weeks() ||