refactor(core): extract dashboard read model
This commit is contained in:
parent
3e06039890
commit
456713586b
13 changed files with 1487 additions and 1328 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
11
docs/tui.md
11
docs/tui.md
|
|
@ -449,6 +449,17 @@ no set-position tie-break; the preceding fact order is session, entry, kind,
|
|||
then set position. The Core extraction must initially preserve these observed
|
||||
semantics or explicitly return to contract review before changing them.
|
||||
|
||||
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` preserves those semantics in
|
||||
the Core API `trainlog_dashboard_load()`. Its query makes the reference Unix
|
||||
second explicit, so rolling boundaries are deterministic without a test-only
|
||||
production clock. The snapshot is caller-owned and retains the characterized
|
||||
128-session, 4096-fact, 128-performance-point, 256-body-point and 12-bucket
|
||||
bounds plus conservative `partial` and `invalid_data` flags. The TUI owns only
|
||||
period selection, the normal `time(NULL)` capture, localization, formatting,
|
||||
layout, rendering and interaction. Consequently
|
||||
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` is complete as part of the same
|
||||
extraction tranche.
|
||||
|
||||
The snapshot reads at most 128 observable sessions, 4096 occurrence facts, 128
|
||||
selected performance points, and 256 points for each body metric. Reaching a
|
||||
bound sets the conservative `partial` flag. At exactly 128 sessions the view is
|
||||
|
|
|
|||
104
tui/include/trainlog/dashboard.h
Normal file
104
tui/include/trainlog/dashboard.h
Normal 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
|
||||
|
|
@ -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
717
tui/src/dashboard.c
Normal 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), ¤t_max))
|
||||
order = trainlog_timestamp_compare(&fact->time, ¤t_max);
|
||||
if (!dashboard->has_explicit_max || order > 0 ||
|
||||
(order == 0 && strcmp(fact->exercise_id,
|
||||
dashboard->max_exercise.exercise_id) < 0)) {
|
||||
dashboard->has_explicit_max = true;
|
||||
dashboard->max_exercise = *exercise;
|
||||
dashboard->max_weight_kg = fact->weight_kg;
|
||||
(void)snprintf(dashboard->max_timestamp,
|
||||
sizeof(dashboard->max_timestamp), "%s", fact->started_at);
|
||||
}
|
||||
}
|
||||
if (comparable_work && exercise != NULL) {
|
||||
int order = has_newest_performance ?
|
||||
trainlog_timestamp_compare(&fact->time, &newest_performance) : 1;
|
||||
if (!has_newest_performance || order > 0 ||
|
||||
(order == 0 && strcmp(fact->exercise_id,
|
||||
dashboard->exercise.exercise_id) < 0)) {
|
||||
dashboard->has_performance = true;
|
||||
dashboard->exercise = *exercise;
|
||||
dashboard->performance_load_mode = fact->load_mode;
|
||||
dashboard->performance_tracking_mode = fact->tracking_mode;
|
||||
dashboard->performance_dose = fact->dose;
|
||||
(void)snprintf(dashboard->performance_equipment_id,
|
||||
sizeof(dashboard->performance_equipment_id), "%s",
|
||||
fact->equipment_id);
|
||||
newest_performance = fact->time;
|
||||
has_newest_performance = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dashboard->has_performance) {
|
||||
TrainlogResolvedEquipment equipment;
|
||||
size_t index;
|
||||
if (trainlog_database_resolve_equipment(database,
|
||||
dashboard->performance_equipment_id, &equipment) ==
|
||||
TRAINLOG_STATUS_OK)
|
||||
(void)snprintf(dashboard->performance_equipment_label,
|
||||
sizeof(dashboard->performance_equipment_label), "%s",
|
||||
equipment.display_name);
|
||||
for (index = 0U; index < fact_count &&
|
||||
dashboard->performance_count < TRAINLOG_DASHBOARD_SESSION_CAPACITY; ++index) {
|
||||
TrainlogDashboardFact *fact = &facts[index];
|
||||
TrainlogExercisePerformancePoint *point;
|
||||
if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 ||
|
||||
fact->load_mode != dashboard->performance_load_mode ||
|
||||
fact->tracking_mode != dashboard->performance_tracking_mode ||
|
||||
fact->dose != dashboard->performance_dose ||
|
||||
strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 ||
|
||||
strcmp(fact->equipment_id,
|
||||
dashboard->performance_equipment_id) != 0 ||
|
||||
!dashboard_in_period(&fact->time, dashboard->period, query->reference_unix_second)) continue;
|
||||
point = &dashboard->performance[dashboard->performance_count++];
|
||||
(void)memset(point, 0, sizeof(*point));
|
||||
(void)snprintf(point->session_id, sizeof(point->session_id), "%s",
|
||||
fact->session_id);
|
||||
(void)snprintf(point->started_at, sizeof(point->started_at), "%s",
|
||||
fact->started_at);
|
||||
point->tracking_mode = fact->tracking_mode;
|
||||
point->load_mode = fact->load_mode;
|
||||
point->actual_set_count = 1U;
|
||||
point->has_performance = 1;
|
||||
point->metric_value = fact->dose;
|
||||
point->has_weight = 1;
|
||||
point->weight_kg = fact->weight_kg;
|
||||
(void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s",
|
||||
fact->equipment_id);
|
||||
}
|
||||
if (dashboard->performance_count == TRAINLOG_DASHBOARD_SESSION_CAPACITY)
|
||||
dashboard->partial = true;
|
||||
qsort(dashboard->performance, dashboard->performance_count,
|
||||
sizeof(dashboard->performance[0]), dashboard_performance_time_compare);
|
||||
}
|
||||
|
||||
for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) {
|
||||
size_t count = 0U;
|
||||
size_t point_index;
|
||||
if (trainlog_database_list_body_metric_points(database,
|
||||
DASHBOARD_BODY_METRICS[exercise_index], body,
|
||||
TRAINLOG_DASHBOARD_BODY_CAPACITY, &count) != TRAINLOG_STATUS_OK) {
|
||||
dashboard->error = true;
|
||||
continue;
|
||||
}
|
||||
if (count == TRAINLOG_DASHBOARD_BODY_CAPACITY) dashboard->partial = true;
|
||||
for (point_index = 0U; point_index < count; ++point_index) {
|
||||
TrainlogTimestampKey candidate;
|
||||
TrainlogTimestampKey current;
|
||||
int order = 1;
|
||||
if (!trainlog_timestamp_parse(body[point_index].observed_at,
|
||||
strlen(body[point_index].observed_at), &candidate)) {
|
||||
dashboard->invalid_data = true;
|
||||
continue;
|
||||
}
|
||||
if (!dashboard_in_period(&candidate, dashboard->period, query->reference_unix_second)) continue;
|
||||
if (newest_body[0] != '\0') {
|
||||
(void)trainlog_timestamp_parse(newest_body, strlen(newest_body), ¤t);
|
||||
order = trainlog_timestamp_compare(&candidate, ¤t);
|
||||
}
|
||||
if (newest_body[0] == '\0' || order > 0 ||
|
||||
(order == 0 && exercise_index < dashboard->body_metric)) {
|
||||
dashboard->has_body = true;
|
||||
dashboard->body_metric = exercise_index;
|
||||
(void)snprintf(newest_body, sizeof(newest_body), "%s",
|
||||
body[point_index].observed_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dashboard->has_body) {
|
||||
size_t count = 0U;
|
||||
size_t point_index;
|
||||
if (trainlog_database_list_body_metric_points(database,
|
||||
DASHBOARD_BODY_METRICS[dashboard->body_metric], body,
|
||||
TRAINLOG_DASHBOARD_BODY_CAPACITY, &count) != TRAINLOG_STATUS_OK) {
|
||||
dashboard->error = true;
|
||||
goto finish;
|
||||
}
|
||||
if (count == TRAINLOG_DASHBOARD_BODY_CAPACITY)
|
||||
dashboard->partial = true;
|
||||
for (point_index = 0U; point_index < count; ++point_index) {
|
||||
TrainlogTimestampKey key;
|
||||
if (!trainlog_timestamp_parse(body[point_index].observed_at,
|
||||
strlen(body[point_index].observed_at), &key)) {
|
||||
dashboard->invalid_data = true;
|
||||
continue;
|
||||
}
|
||||
if (dashboard_in_period(&key, dashboard->period, query->reference_unix_second))
|
||||
dashboard->body[dashboard->body_count++] = body[point_index];
|
||||
}
|
||||
qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]),
|
||||
dashboard_body_time_compare);
|
||||
}
|
||||
|
||||
finish:
|
||||
if (trainlog_database_read_snapshot_end(database, !dashboard->error) !=
|
||||
TRAINLOG_STATUS_OK) dashboard->error = true;
|
||||
free(exercises);
|
||||
free(facts);
|
||||
return dashboard->error ? TRAINLOG_STATUS_DATABASE_ERROR : TRAINLOG_STATUS_OK;
|
||||
}
|
||||
809
tui/src/tui.c
809
tui/src/tui.c
|
|
@ -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), ¤t_max))
|
||||
order = trainlog_timestamp_compare(&fact->time, ¤t_max);
|
||||
if (!dashboard->has_explicit_max || order > 0 ||
|
||||
(order == 0 && strcmp(fact->exercise_id,
|
||||
dashboard->max_exercise.exercise_id) < 0)) {
|
||||
dashboard->has_explicit_max = true;
|
||||
dashboard->max_exercise = *exercise;
|
||||
dashboard->max_weight_kg = fact->weight_kg;
|
||||
(void)snprintf(dashboard->max_timestamp,
|
||||
sizeof(dashboard->max_timestamp), "%s", fact->started_at);
|
||||
}
|
||||
}
|
||||
if (comparable_work && exercise != NULL) {
|
||||
int order = has_newest_performance ?
|
||||
trainlog_timestamp_compare(&fact->time, &newest_performance) : 1;
|
||||
if (!has_newest_performance || order > 0 ||
|
||||
(order == 0 && strcmp(fact->exercise_id,
|
||||
dashboard->exercise.exercise_id) < 0)) {
|
||||
dashboard->has_performance = true;
|
||||
dashboard->exercise = *exercise;
|
||||
dashboard->performance_load_mode = fact->load_mode;
|
||||
dashboard->performance_tracking_mode = fact->tracking_mode;
|
||||
dashboard->performance_dose = fact->dose;
|
||||
(void)snprintf(dashboard->performance_equipment_id,
|
||||
sizeof(dashboard->performance_equipment_id), "%s",
|
||||
fact->equipment_id);
|
||||
newest_performance = fact->time;
|
||||
has_newest_performance = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dashboard->has_performance) {
|
||||
TrainlogResolvedEquipment equipment;
|
||||
size_t index;
|
||||
if (trainlog_database_resolve_equipment(app->database,
|
||||
dashboard->performance_equipment_id, &equipment) ==
|
||||
TRAINLOG_STATUS_OK)
|
||||
(void)snprintf(dashboard->performance_equipment_label,
|
||||
sizeof(dashboard->performance_equipment_label), "%s",
|
||||
equipment.display_name);
|
||||
for (index = 0U; index < fact_count &&
|
||||
dashboard->performance_count < MAX_SESSIONS; ++index) {
|
||||
TrainlogDashboardFact *fact = &facts[index];
|
||||
TrainlogExercisePerformancePoint *point;
|
||||
if (fact->kind != TRAINLOG_DASHBOARD_WORK || fact->dose <= 0 ||
|
||||
fact->load_mode != dashboard->performance_load_mode ||
|
||||
fact->tracking_mode != dashboard->performance_tracking_mode ||
|
||||
fact->dose != dashboard->performance_dose ||
|
||||
strcmp(fact->exercise_id, dashboard->exercise.exercise_id) != 0 ||
|
||||
strcmp(fact->equipment_id,
|
||||
dashboard->performance_equipment_id) != 0 ||
|
||||
!dashboard_in_period(&fact->time, dashboard->period)) continue;
|
||||
point = &dashboard->performance[dashboard->performance_count++];
|
||||
(void)memset(point, 0, sizeof(*point));
|
||||
(void)snprintf(point->session_id, sizeof(point->session_id), "%s",
|
||||
fact->session_id);
|
||||
(void)snprintf(point->started_at, sizeof(point->started_at), "%s",
|
||||
fact->started_at);
|
||||
point->tracking_mode = fact->tracking_mode;
|
||||
point->load_mode = fact->load_mode;
|
||||
point->actual_set_count = 1U;
|
||||
point->has_performance = 1;
|
||||
point->metric_value = fact->dose;
|
||||
point->has_weight = 1;
|
||||
point->weight_kg = fact->weight_kg;
|
||||
(void)snprintf(point->equipment_id, sizeof(point->equipment_id), "%s",
|
||||
fact->equipment_id);
|
||||
}
|
||||
if (dashboard->performance_count == MAX_SESSIONS)
|
||||
dashboard->partial = true;
|
||||
qsort(dashboard->performance, dashboard->performance_count,
|
||||
sizeof(dashboard->performance[0]), dashboard_performance_time_compare);
|
||||
}
|
||||
|
||||
for (exercise_index = 0U; exercise_index < 14U; ++exercise_index) {
|
||||
size_t count = 0U;
|
||||
size_t point_index;
|
||||
if (trainlog_database_list_body_metric_points(app->database,
|
||||
BODY_METRICS[exercise_index].metric, body,
|
||||
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
|
||||
dashboard->error = true;
|
||||
continue;
|
||||
}
|
||||
if (count == MAX_BODY_METRIC_POINTS) dashboard->partial = true;
|
||||
for (point_index = 0U; point_index < count; ++point_index) {
|
||||
TrainlogTimestampKey candidate;
|
||||
TrainlogTimestampKey current;
|
||||
int order = 1;
|
||||
if (!trainlog_timestamp_parse(body[point_index].observed_at,
|
||||
strlen(body[point_index].observed_at), &candidate)) {
|
||||
dashboard->invalid_data = true;
|
||||
continue;
|
||||
}
|
||||
if (!dashboard_in_period(&candidate, dashboard->period)) continue;
|
||||
if (newest_body[0] != '\0') {
|
||||
(void)trainlog_timestamp_parse(newest_body, strlen(newest_body), ¤t);
|
||||
order = trainlog_timestamp_compare(&candidate, ¤t);
|
||||
}
|
||||
if (newest_body[0] == '\0' || order > 0 ||
|
||||
(order == 0 && exercise_index < dashboard->body_metric)) {
|
||||
dashboard->has_body = true;
|
||||
dashboard->body_metric = exercise_index;
|
||||
(void)snprintf(newest_body, sizeof(newest_body), "%s",
|
||||
body[point_index].observed_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dashboard->has_body) {
|
||||
size_t count = 0U;
|
||||
size_t point_index;
|
||||
if (trainlog_database_list_body_metric_points(app->database,
|
||||
BODY_METRICS[dashboard->body_metric].metric, body,
|
||||
MAX_BODY_METRIC_POINTS, &count) != TRAINLOG_STATUS_OK) {
|
||||
dashboard->error = true;
|
||||
goto finish;
|
||||
}
|
||||
if (count == MAX_BODY_METRIC_POINTS)
|
||||
dashboard->partial = true;
|
||||
for (point_index = 0U; point_index < count; ++point_index) {
|
||||
TrainlogTimestampKey key;
|
||||
if (!trainlog_timestamp_parse(body[point_index].observed_at,
|
||||
strlen(body[point_index].observed_at), &key)) {
|
||||
dashboard->invalid_data = true;
|
||||
continue;
|
||||
}
|
||||
if (dashboard_in_period(&key, dashboard->period))
|
||||
dashboard->body[dashboard->body_count++] = body[point_index];
|
||||
}
|
||||
qsort(dashboard->body, dashboard->body_count, sizeof(dashboard->body[0]),
|
||||
dashboard_body_time_compare);
|
||||
}
|
||||
|
||||
finish:
|
||||
if (trainlog_database_read_snapshot_end(app->database, !dashboard->error) !=
|
||||
TRAINLOG_STATUS_OK) dashboard->error = true;
|
||||
free(exercises);
|
||||
free(facts);
|
||||
query.reference_unix_second = (int64_t)now;
|
||||
(void)trainlog_dashboard_load(app->database, &query, &app->dashboard);
|
||||
}
|
||||
|
||||
static void sync_controller_load_history(TrainlogSyncController *sync)
|
||||
|
|
@ -6442,6 +5714,11 @@ static void app_shell_dashboard_zone_chart(TrainlogAppContext *app,
|
|||
if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows;
|
||||
}
|
||||
for (index = 0U; index < bucket_rows; ++index) {
|
||||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(
|
||||
dashboard->zones[index].zone_id);
|
||||
const char *label = dashboard->zones[index].zone_id[0] == '\0'
|
||||
? trainlog_presentation_text("ui.unclassified")
|
||||
: (zone == NULL ? dashboard->zones[index].zone_id : zone->display_name);
|
||||
int row = top + (int)index * row_stride;
|
||||
int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count *
|
||||
(size_t)bar_width + maximum - 1U) / maximum);
|
||||
|
|
@ -6450,7 +5727,7 @@ static void app_shell_dashboard_zone_chart(TrainlogAppContext *app,
|
|||
* independently scaled partial-height rows can never form the former
|
||||
* solid staircase. Wide layouts leave a blank row between buckets. */
|
||||
trainlog_surface_printf(app->content, row, left,
|
||||
"%-*.*s", label_width, label_width, dashboard->zones[index].label);
|
||||
"%-*.*s", label_width, label_width, label);
|
||||
trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||||
for (block = 0; block < blocks; ++block)
|
||||
|
|
@ -6496,9 +5773,9 @@ static void app_shell_dashboard_frequency_chart(TrainlogAppContext *app,
|
|||
dashboard->weeks[dashboard->week_count - 1U].maxima);
|
||||
baseline = top + height;
|
||||
for (index = 0U; index < dashboard->week_count; ++index) {
|
||||
const char *label = index + 1U == dashboard->week_count
|
||||
? trainlog_presentation_text("dashboard.current")
|
||||
: dashboard->weeks[index].label;
|
||||
char label[12];
|
||||
dashboard_period_bucket_label(dashboard->period, index,
|
||||
dashboard->week_count, label);
|
||||
int label_margin = 3;
|
||||
int x = dashboard->week_count > 1U
|
||||
? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) /
|
||||
|
|
@ -6546,12 +5823,12 @@ static void __attribute__((unused)) app_shell_render_dashboard(TrainlogAppContex
|
|||
body_values[index] = dashboard->body[index].value;
|
||||
trainlog_surface_printf(app->content, 2, 2,
|
||||
"Période %s %s %s %s %s",
|
||||
dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j",
|
||||
dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j",
|
||||
dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j",
|
||||
dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an",
|
||||
dashboard->period == TRAINLOG_DASHBOARD_7_DAYS ? "[7j]" : "7j",
|
||||
dashboard->period == TRAINLOG_DASHBOARD_30_DAYS ? "[30j]" : "30j",
|
||||
dashboard->period == TRAINLOG_DASHBOARD_90_DAYS ? "[90j]" : "90j",
|
||||
dashboard->period == TRAINLOG_DASHBOARD_YEAR ? "[1an]" : "1an",
|
||||
trainlog_presentation_source(
|
||||
dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout"));
|
||||
dashboard->period == TRAINLOG_DASHBOARD_ALL ? "[Tout]" : "Tout"));
|
||||
trainlog_surface_printf(app->content, 3, 2,
|
||||
"Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu",
|
||||
dashboard->completed_session_count, dashboard->performed_set_count,
|
||||
|
|
|
|||
514
tui/tests/test_dashboard.c
Normal file
514
tui/tests/test_dashboard.c
Normal 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;
|
||||
}
|
||||
9
tui/tests/test_dashboard_header.c
Normal file
9
tui/tests/test_dashboard_header.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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() ||
|
||||
|
|
|
|||
Loading…
Reference in a new issue