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
|
and run-scoped terminal planes keep Notcurses infrastructure separate from
|
||||||
product semantics.
|
product semantics.
|
||||||
|
|
||||||
The current Dashboard implementation retains one known layering violation:
|
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` resolves the former Dashboard
|
||||||
`tui.c` uses SQLite and `database_internal.h` directly to build Dashboard facts
|
layering violation. `tui/src/dashboard.c` alone owns its SQLite fact projection,
|
||||||
and calculations. `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` now captures
|
normalization, bounds, ordering, rolling windows, aggregates and legacy-data
|
||||||
that behavior, including its bounds and legacy-data flags. This remains a
|
flags. Its public `trainlog/dashboard.h` boundary contains no SQLite, Notcurses
|
||||||
blocking debt for the Web Dashboard: before any Dashboard HTTP endpoint exists,
|
or private TUI type. The TUI calls that service with an explicit reference Unix
|
||||||
the query and calculations must move incrementally into a typed Core read model
|
second and presents the caller-owned bounded snapshot; it neither includes
|
||||||
and the TUI must consume that read model. This requirement does not authorize a
|
`database_internal.h` for the Dashboard nor reproduces its calculations.
|
||||||
global `tui.c` refactor.
|
`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
|
### Local Web application
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ parallel implementation of its rules.
|
||||||
| Application shell | `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` |
|
| Application shell | `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` |
|
||||||
| Statistics | `STATS_V1=IMPLEMENTED` |
|
| Statistics | `STATS_V1=IMPLEMENTED` |
|
||||||
| Dashboard characterization | `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| 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` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` |
|
||||||
|
|
||||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
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.
|
including selected-language formatting without changing canonical data.
|
||||||
|
|
||||||
Rendering is not permitted to own SQL or business rules. Statistics are
|
Rendering is not permitted to own SQL or business rules. Statistics are
|
||||||
read-only projections; they are not persisted as facts. The current TUI
|
read-only projections; they are not persisted as facts. The former Dashboard
|
||||||
Dashboard still has a known exception: its direct SQLite/
|
exception is resolved: its typed Core read model owns the SQLite projection and
|
||||||
`database_internal.h` fact projection is now frozen by characterization tests.
|
all characterized calculations, while the TUI supplies query state and renders
|
||||||
It must next be extracted into a typed Core read model and consumed by the TUI
|
the result. No Dashboard HTTP endpoint exists yet.
|
||||||
before any Web Dashboard endpoint is created. This is an incremental
|
|
||||||
extraction, not authorization for a global `tui.c` refactor.
|
|
||||||
|
|
||||||
## Local Web
|
## 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
|
`TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and
|
||||||
diff safety checks pass in the final audit evidence.
|
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,
|
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
|
||||||
formatting, layout-invariance and source-derived text-boundary tests, plus
|
formatting, layout-invariance and source-derived text-boundary tests, plus
|
||||||
Android resource-parity, language-owner, typed sync-presentation, and
|
Android resource-parity, language-owner, typed sync-presentation, and
|
||||||
|
|
@ -177,9 +181,9 @@ smoke test are not automated. The latter is why
|
||||||
|
|
||||||
## Active limitations
|
## Active limitations
|
||||||
|
|
||||||
- `TRAINLOG_WEB_V1` is contract-only; Dashboard characterization is frozen and
|
- `TRAINLOG_WEB_V1` remains contract-only; Dashboard characterization, Core
|
||||||
the next implementation step is Core read-model extraction, not React or
|
extraction and TUI adoption are frozen. The next implementation cursor is
|
||||||
HTTP infrastructure.
|
CLI/local HTTP infrastructure, not React.
|
||||||
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
|
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
|
||||||
- AI proposal exchange still awaits one real Drive plus Android-triggered
|
- AI proposal exchange still awaits one real Drive plus Android-triggered
|
||||||
bidirectional smoke test.
|
bidirectional smoke test.
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,17 @@ architecture is canonical, but no Web code exists yet.
|
||||||
## Current cursor
|
## Current cursor
|
||||||
|
|
||||||
```text
|
```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
|
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` and
|
||||||
direct SQLite and `database_internal.h` projection into a typed Core read
|
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN`. The characterized Dashboard SQL,
|
||||||
model without changing its results. Then adapt the TUI to consume that read
|
facts, limits and calculations now live in the typed Core service
|
||||||
model. This incremental sequence is a blocking prerequisite for a Web
|
`trainlog_dashboard_load()`; the TUI supplies the selected period and current
|
||||||
Dashboard endpoint and does not authorize a global `tui.c` refactor.
|
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
|
## TRAINLOG_WEB_V1 implementation gate
|
||||||
|
|
||||||
|
|
@ -45,8 +48,8 @@ Implementation order is fixed as:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
WEB_DASHBOARD_CHARACTERIZATION_V1
|
WEB_DASHBOARD_CHARACTERIZATION_V1
|
||||||
-> WEB_DASHBOARD_CORE_READ_MODEL_V1
|
-> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN]
|
||||||
-> WEB_TUI_READ_MODEL_ADOPTION_V1
|
-> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN]
|
||||||
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1
|
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1
|
||||||
-> WEB_FRONTEND_SHELL_V1
|
-> WEB_FRONTEND_SHELL_V1
|
||||||
-> WEB_DASHBOARD_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
|
Automated coverage does not replace a real device/manual visual language-switch
|
||||||
smoke; that remains explicit manual validation.
|
smoke; that remains explicit manual validation.
|
||||||
|
|
||||||
`WEB_DASHBOARD_CHARACTERIZATION_V1` exercises the production Dashboard
|
`WEB_DASHBOARD_CHARACTERIZATION_V1` now exercises the production Core service
|
||||||
projection through a real in-memory SQLite database. Its fixed test clock
|
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
|
covers an empty training history, exact rolling-window inclusion immediately
|
||||||
before/at/after the 30-day boundary, valid actual history without `ended_at`,
|
before/at/after the 30-day boundary, valid actual history without `ended_at`,
|
||||||
and plan-only exclusion. It covers repeated occurrences, heterogeneous REPS
|
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
|
and `partial` flag. Existing regressions continue to cover malformed legacy
|
||||||
timestamps, body-metric selection, responsive rendering, all-history buckets,
|
timestamps, body-metric selection, responsive rendering, all-history buckets,
|
||||||
strictly earlier exact-dose improvement, and explicit-MAX separation.
|
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:
|
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
|
then set position. The Core extraction must initially preserve these observed
|
||||||
semantics or explicitly return to contract review before changing them.
|
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
|
The snapshot reads at most 128 observable sessions, 4096 occurrence facts, 128
|
||||||
selected performance points, and 256 points for each body metric. Reaching a
|
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
|
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/body_analytics.c',
|
||||||
'src/catalog.c',
|
'src/catalog.c',
|
||||||
'src/database.c',
|
'src/database.c',
|
||||||
|
'src/dashboard.c',
|
||||||
'src/duration.c',
|
'src/duration.c',
|
||||||
'src/id.c',
|
'src/id.c',
|
||||||
'src/timeutil.c',
|
'src/timeutil.c',
|
||||||
|
|
@ -200,6 +201,22 @@ test_body_zones = executable(
|
||||||
)
|
)
|
||||||
test('body_zones', test_body_zones)
|
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 = executable(
|
||||||
'test_training_knowledge',
|
'test_training_knowledge',
|
||||||
'tests/test_training_knowledge.c',
|
'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;
|
||||||
|
}
|
||||||
811
tui/src/tui.c
811
tui/src/tui.c
|
|
@ -24,7 +24,6 @@
|
||||||
|
|
||||||
#include <utf8proc.h>
|
#include <utf8proc.h>
|
||||||
#include <uuid/uuid.h>
|
#include <uuid/uuid.h>
|
||||||
#include <sqlite3.h>
|
|
||||||
|
|
||||||
#include "trainlog/terminal.h"
|
#include "trainlog/terminal.h"
|
||||||
#include "trainlog/app_shell.h"
|
#include "trainlog/app_shell.h"
|
||||||
|
|
@ -36,6 +35,7 @@
|
||||||
#include "trainlog/body_zone_catalog.h"
|
#include "trainlog/body_zone_catalog.h"
|
||||||
#include "trainlog/catalog.h"
|
#include "trainlog/catalog.h"
|
||||||
#include "trainlog/duration.h"
|
#include "trainlog/duration.h"
|
||||||
|
#include "trainlog/dashboard.h"
|
||||||
#include "trainlog/equipment_catalog.h"
|
#include "trainlog/equipment_catalog.h"
|
||||||
#include "trainlog/exercise_name_catalog.h"
|
#include "trainlog/exercise_name_catalog.h"
|
||||||
#include "trainlog/id.h"
|
#include "trainlog/id.h"
|
||||||
|
|
@ -53,7 +53,6 @@
|
||||||
#include "trainlog/training_knowledge.h"
|
#include "trainlog/training_knowledge.h"
|
||||||
#include "trainlog/usb.h"
|
#include "trainlog/usb.h"
|
||||||
#include "timestamp.h"
|
#include "timestamp.h"
|
||||||
#include "database_internal.h"
|
|
||||||
|
|
||||||
static void presentation_surface_printf(TrainlogSurface *surface, int row,
|
static void presentation_surface_printf(TrainlogSurface *surface, int row,
|
||||||
int column, const char *format, ...)
|
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 MAX_BODY_METRIC_POINTS 256U
|
||||||
#define EXERCISE_GRAPH_POINTS 12U
|
#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_V02_POLISH */
|
||||||
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
|
/* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */
|
||||||
|
|
@ -2529,87 +2525,6 @@ typedef struct TrainlogSyncController {
|
||||||
TrainlogAppSyncRun run;
|
TrainlogAppSyncRun run;
|
||||||
} TrainlogSyncController;
|
} 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 {
|
typedef struct TrainlogAppContext {
|
||||||
TrainlogDatabase *database;
|
TrainlogDatabase *database;
|
||||||
TrainlogTerminal *terminal;
|
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);
|
app->body_has_profile = body_analytics_profile_load(&app->body_profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool dashboard_copy_sql_text(sqlite3_stmt *statement, int column,
|
static const char *dashboard_period_bucket_summary(TrainlogDashboardPeriod period)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case TRAINLOG_STATS_7_DAYS: return 7;
|
case TRAINLOG_DASHBOARD_7_DAYS: return "7 jours · quotidien";
|
||||||
case TRAINLOG_STATS_30_DAYS: return 30;
|
case TRAINLOG_DASHBOARD_30_DAYS: return "30 jours · pas de 5 j";
|
||||||
case TRAINLOG_STATS_90_DAYS: return 90;
|
case TRAINLOG_DASHBOARD_90_DAYS: return "90 jours · pas de 15 j";
|
||||||
case TRAINLOG_STATS_YEAR: return 365;
|
case TRAINLOG_DASHBOARD_YEAR: return "1 an · pas de 31 j";
|
||||||
case TRAINLOG_STATS_ALL: return 0;
|
case TRAINLOG_DASHBOARD_ALL: return "Tout · 12 périodes max.";
|
||||||
}
|
|
||||||
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.";
|
|
||||||
}
|
}
|
||||||
return "Période";
|
return "Période";
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool dashboard_fact_external_context_qualifies(TrainlogAppContext *app,
|
static void dashboard_period_bucket_label(TrainlogDashboardPeriod period,
|
||||||
const TrainlogDashboardFact *fact)
|
size_t index, size_t count, char output[12])
|
||||||
{
|
{
|
||||||
TrainlogResolvedEquipment equipment;
|
if (index + 1U == count) {
|
||||||
if (fact->equipment_id[0] == '\0' ||
|
(void)snprintf(output, 12U, "%s",
|
||||||
trainlog_database_resolve_equipment(app->database, fact->equipment_id,
|
trainlog_presentation_text("dashboard.current"));
|
||||||
&equipment) != TRAINLOG_STATUS_OK) return false;
|
} else if (period == TRAINLOG_DASHBOARD_7_DAYS) {
|
||||||
return strcmp(equipment.load_semantics, "external") == 0;
|
(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static TrainlogExercise *dashboard_find_exercise(TrainlogExercise *exercises,
|
/* The TUI owns only the reference clock and selected presentation period. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
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. */
|
|
||||||
static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app)
|
static void __attribute__((unused)) app_shell_load_dashboard(TrainlogAppContext *app)
|
||||||
{
|
{
|
||||||
TrainlogDashboardSnapshot *dashboard = &app->dashboard;
|
TrainlogDashboardQuery query;
|
||||||
TrainlogStatisticsPeriod period = dashboard->period;
|
TrainlogDashboardPeriod period = app->dashboard.period;
|
||||||
TrainlogExercise *exercises = NULL;
|
time_t now = time(NULL);
|
||||||
TrainlogDashboardFact *facts = NULL;
|
query.period = period;
|
||||||
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();
|
|
||||||
if (now == (time_t)-1) {
|
if (now == (time_t)-1) {
|
||||||
dashboard->error = true;
|
(void)memset(&app->dashboard, 0, sizeof(app->dashboard));
|
||||||
goto finish;
|
app->dashboard.period = period;
|
||||||
|
app->dashboard.error = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
current_day = dashboard_floor_div((int64_t)now +
|
query.reference_unix_second = (int64_t)now;
|
||||||
INT64_C(719162) * INT64_C(86400), INT64_C(86400));
|
(void)trainlog_dashboard_load(app->database, &query, &app->dashboard);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void sync_controller_load_history(TrainlogSyncController *sync)
|
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;
|
if (summary_row >= (size_t)rows && bucket_rows > 1U) --bucket_rows;
|
||||||
}
|
}
|
||||||
for (index = 0U; index < bucket_rows; ++index) {
|
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 row = top + (int)index * row_stride;
|
||||||
int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count *
|
int blocks = maximum == 0U ? 0 : (int)((dashboard->zones[index].count *
|
||||||
(size_t)bar_width + maximum - 1U) / maximum);
|
(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
|
* independently scaled partial-height rows can never form the former
|
||||||
* solid staircase. Wide layouts leave a blank row between buckets. */
|
* solid staircase. Wide layouts leave a blank row between buckets. */
|
||||||
trainlog_surface_printf(app->content, row, left,
|
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_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH,
|
||||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||||||
for (block = 0; block < blocks; ++block)
|
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);
|
dashboard->weeks[dashboard->week_count - 1U].maxima);
|
||||||
baseline = top + height;
|
baseline = top + height;
|
||||||
for (index = 0U; index < dashboard->week_count; ++index) {
|
for (index = 0U; index < dashboard->week_count; ++index) {
|
||||||
const char *label = index + 1U == dashboard->week_count
|
char label[12];
|
||||||
? trainlog_presentation_text("dashboard.current")
|
dashboard_period_bucket_label(dashboard->period, index,
|
||||||
: dashboard->weeks[index].label;
|
dashboard->week_count, label);
|
||||||
int label_margin = 3;
|
int label_margin = 3;
|
||||||
int x = dashboard->week_count > 1U
|
int x = dashboard->week_count > 1U
|
||||||
? left + label_margin + (int)((index * (size_t)(width - 2 * label_margin - 1)) /
|
? 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;
|
body_values[index] = dashboard->body[index].value;
|
||||||
trainlog_surface_printf(app->content, 2, 2,
|
trainlog_surface_printf(app->content, 2, 2,
|
||||||
"Période %s %s %s %s %s",
|
"Période %s %s %s %s %s",
|
||||||
dashboard->period == TRAINLOG_STATS_7_DAYS ? "[7j]" : "7j",
|
dashboard->period == TRAINLOG_DASHBOARD_7_DAYS ? "[7j]" : "7j",
|
||||||
dashboard->period == TRAINLOG_STATS_30_DAYS ? "[30j]" : "30j",
|
dashboard->period == TRAINLOG_DASHBOARD_30_DAYS ? "[30j]" : "30j",
|
||||||
dashboard->period == TRAINLOG_STATS_90_DAYS ? "[90j]" : "90j",
|
dashboard->period == TRAINLOG_DASHBOARD_90_DAYS ? "[90j]" : "90j",
|
||||||
dashboard->period == TRAINLOG_STATS_YEAR ? "[1an]" : "1an",
|
dashboard->period == TRAINLOG_DASHBOARD_YEAR ? "[1an]" : "1an",
|
||||||
trainlog_presentation_source(
|
trainlog_presentation_source(
|
||||||
dashboard->period == TRAINLOG_STATS_ALL ? "[Tout]" : "Tout"));
|
dashboard->period == TRAINLOG_DASHBOARD_ALL ? "[Tout]" : "Tout"));
|
||||||
trainlog_surface_printf(app->content, 3, 2,
|
trainlog_surface_printf(app->content, 3, 2,
|
||||||
"Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu",
|
"Séances %zu Séries %zu Exercices pratiqués %zu MAX %zu",
|
||||||
dashboard->completed_session_count, dashboard->performed_set_count,
|
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.
|
# non-copy token: punctuation/layout, a protocol token, or an internal key.
|
||||||
EXCLUDED = {
|
EXCLUDED = {
|
||||||
"tui/src/tui.c": {
|
"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/chart.c": {254:"◆ %.2f %s"},
|
||||||
"tui/src/app_shell.c": {317:"…"},
|
"tui/src/app_shell.c": {317:"…"},
|
||||||
"tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"},
|
"tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"},
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@
|
||||||
|
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <stdarg.h>
|
#include <stdarg.h>
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include "../src/database_internal.h"
|
||||||
|
|
||||||
/* The workflows remain file-private in production. Including their translation
|
/* The workflows remain file-private in production. Including their translation
|
||||||
* unit lets this test drive the exact code paths with the terminal port mocked,
|
* unit lets this test drive the exact code paths with the terminal port mocked,
|
||||||
* without adding a public test API to Trainlog. */
|
* without adding a public test API to Trainlog. */
|
||||||
#define TRAINLOG_DASHBOARD_CHARACTERIZATION_TEST 1
|
|
||||||
#include "../src/tui.c"
|
#include "../src/tui.c"
|
||||||
|
|
||||||
struct TrainlogTerminal {
|
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;
|
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;
|
size_t index;
|
||||||
CHECK(dashboard_characterization_fix_clock("2026-09-16T12:00:00Z"));
|
(void)memset(dashboard->weeks, 0, sizeof(dashboard->weeks));
|
||||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
dashboard->period = TRAINLOG_DASHBOARD_30_DAYS;
|
||||||
(void)memset(&app, 0, sizeof(app));
|
dashboard->week_count = 6U;
|
||||||
app.database = database;
|
for (index = 0U; index < dashboard->week_count; ++index) {
|
||||||
app.dashboard.period = TRAINLOG_STATS_30_DAYS;
|
dashboard->weeks[index].start_day = 971 + (int64_t)index * 5;
|
||||||
app_shell_load_dashboard(&app);
|
dashboard->weeks[index].span_days = 5;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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.performed_set_count = 9U;
|
||||||
app.dashboard.distinct_exercise_count = 3U;
|
app.dashboard.distinct_exercise_count = 3U;
|
||||||
app.dashboard.explicit_max_count = 1U;
|
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].sessions = 4U;
|
||||||
app.dashboard.weeks[5].maxima = 1U;
|
app.dashboard.weeks[5].maxima = 1U;
|
||||||
app.dashboard.weeks[5].working_improvements = 2U;
|
app.dashboard.weeks[5].working_improvements = 2U;
|
||||||
app.dashboard.weeks[5].max_improvements = 1U;
|
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
|
/* A lone measurement is text-only; event blocks are factual period-bucket
|
||||||
* counts and the accessible totals expose their meaning. */
|
* counts and the accessible totals expose their meaning. */
|
||||||
dashboard_prepare_render(&app, &terminal, &surface, 120, 35, 96, 29);
|
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.
|
/* Frequency geometry has one shared x coordinate per period bucket.
|
||||||
* The end buckets also prove a wide chart uses its useful span. */
|
* The end buckets also prove a wide chart uses its useful span. */
|
||||||
(void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks));
|
(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) {
|
for (index = 0U; index < 6U; ++index) {
|
||||||
app.dashboard.weeks[index].sessions = 11U + index;
|
app.dashboard.weeks[index].sessions = 11U + index;
|
||||||
app.dashboard.weeks[index].maxima = 1U;
|
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);
|
app_shell_dashboard_frequency_chart(&app, &app.dashboard, 11, 4, 88, 7);
|
||||||
for (index = 0U; index < 6U; ++index) {
|
for (index = 0U; index < 6U; ++index) {
|
||||||
char count[8];
|
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 label_column;
|
||||||
int x;
|
int x;
|
||||||
(void)snprintf(count, sizeof(count), "%zu", 11U + index);
|
(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
|
/* No classified performance events still occupies the selected-period
|
||||||
* timeline, with dots and an explicit explanation. */
|
* timeline, with dots and an explicit explanation. */
|
||||||
(void)memset(&app.dashboard.weeks, 0, sizeof(app.dashboard.weeks));
|
(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;
|
app.dashboard.body_count = 1U;
|
||||||
dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16);
|
dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16);
|
||||||
app_shell_render_dashboard(&app);
|
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));
|
(void)memset(&app, 0, sizeof(app));
|
||||||
app.database = database;
|
app.database = database;
|
||||||
app.dashboard.period = TRAINLOG_STATS_ALL;
|
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
|
||||||
app_shell_load_dashboard(&app);
|
app_shell_load_dashboard(&app);
|
||||||
CHECK(!app.dashboard.error);
|
CHECK(!app.dashboard.error);
|
||||||
CHECK(app.dashboard.week_count >= 1U &&
|
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
|
/* Tout projects the complete represented history instead of silently
|
||||||
* retaining the former six-week viewport. */
|
* 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));
|
TRAINLOG_LOAD_EXTERNAL, "leg_press", 20.0));
|
||||||
(void)memset(&app, 0, sizeof(app));
|
(void)memset(&app, 0, sizeof(app));
|
||||||
app.database = database;
|
app.database = database;
|
||||||
app.dashboard.period = TRAINLOG_STATS_ALL;
|
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
|
||||||
app_shell_load_dashboard(&app);
|
app_shell_load_dashboard(&app);
|
||||||
CHECK(!app.dashboard.error);
|
CHECK(!app.dashboard.error);
|
||||||
CHECK(app.dashboard.week_count >= 1U &&
|
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);
|
CHECK(app.dashboard.weeks[app.dashboard.week_count - 1U].sessions == 0U);
|
||||||
dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20);
|
dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20);
|
||||||
app_shell_dashboard_frequency_chart(&app, &app.dashboard, 3, 4, 64, 3);
|
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));
|
(void)memset(&app, 0, sizeof(app));
|
||||||
app.database = database;
|
app.database = database;
|
||||||
app.dashboard.period = TRAINLOG_STATS_ALL;
|
app.dashboard.period = TRAINLOG_DASHBOARD_ALL;
|
||||||
app_shell_load_dashboard(&app);
|
app_shell_load_dashboard(&app);
|
||||||
CHECK(!app.dashboard.error && app.dashboard.invalid_data);
|
CHECK(!app.dashboard.error && app.dashboard.invalid_data);
|
||||||
CHECK(app.dashboard.completed_session_count == 7U);
|
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-000000000001",
|
||||||
"ex_10000000-0000-4000-8000-000000000002") == TRAINLOG_STATUS_OK);
|
"ex_10000000-0000-4000-8000-000000000002") == TRAINLOG_STATUS_OK);
|
||||||
(void)memset(&app, 0, sizeof(app)); app.database = database;
|
(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);
|
app_shell_load_dashboard(&app);
|
||||||
CHECK(!app.dashboard.error && app.dashboard.zone_count == 4U);
|
CHECK(!app.dashboard.error && app.dashboard.zone_count == 4U);
|
||||||
for (index = 0U; index < app.dashboard.zone_count; ++index) {
|
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);
|
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);
|
app.dashboard.zones[index].count == 2U);
|
||||||
unclassified = unclassified ||
|
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);
|
app.dashboard.zones[index].count == 3U);
|
||||||
}
|
}
|
||||||
CHECK(chest && back && unclassified);
|
CHECK(chest && back && unclassified);
|
||||||
|
|
@ -2640,8 +2140,8 @@ static bool test_stats_detail_charts_and_global_zone_projection(void)
|
||||||
size_t geometry;
|
size_t geometry;
|
||||||
app.dashboard.zone_count = sizeof(counts) / sizeof(counts[0]);
|
app.dashboard.zone_count = sizeof(counts) / sizeof(counts[0]);
|
||||||
for (index = 0U; index < app.dashboard.zone_count; ++index) {
|
for (index = 0U; index < app.dashboard.zone_count; ++index) {
|
||||||
(void)snprintf(app.dashboard.zones[index].label,
|
(void)snprintf(app.dashboard.zones[index].zone_id,
|
||||||
sizeof(app.dashboard.zones[index].label), "Zone %zu", index + 1U);
|
sizeof(app.dashboard.zones[index].zone_id), "Zone %zu", index + 1U);
|
||||||
app.dashboard.zones[index].count = counts[index];
|
app.dashboard.zones[index].count = counts[index];
|
||||||
}
|
}
|
||||||
for (geometry = 0U; geometry < 4U; ++geometry) {
|
for (geometry = 0U; geometry < 4U; ++geometry) {
|
||||||
|
|
@ -2986,9 +2486,6 @@ int main(void)
|
||||||
!test_shell_sync_history_neutralizes_legacy_directions() ||
|
!test_shell_sync_history_neutralizes_legacy_directions() ||
|
||||||
!test_equipment_collector_finds_match_after_first_pages() ||
|
!test_equipment_collector_finds_match_after_first_pages() ||
|
||||||
!test_exercise_merge_action_search_confirm_selects_target() ||
|
!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_sparse_series_names_frequency_and_footer() ||
|
||||||
!test_stats_dashboard_uses_real_exact_time_snapshots_responsively() ||
|
!test_stats_dashboard_uses_real_exact_time_snapshots_responsively() ||
|
||||||
!test_stats_dashboard_uses_represented_local_weeks() ||
|
!test_stats_dashboard_uses_represented_local_weeks() ||
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue