717 lines
32 KiB
C
717 lines
32 KiB
C
/**
|
|
* @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;
|
|
}
|