859 lines
47 KiB
C
859 lines
47 KiB
C
/**
|
|
* @file statistics.c
|
|
* @brief Read-only SQLite aggregation for auditable TUI statistics.
|
|
*/
|
|
|
|
#include "trainlog/statistics.h"
|
|
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include "database_internal.h"
|
|
#include "timestamp.h"
|
|
|
|
#define SECONDS_PER_DAY INT64_C(86400)
|
|
#define SECONDS_PER_WEEK (INT64_C(7) * SECONDS_PER_DAY)
|
|
|
|
static TrainlogStatus prepare(sqlite3 *database, const char *sql,
|
|
sqlite3_stmt **statement)
|
|
{
|
|
return database != NULL && sql != NULL && statement != NULL &&
|
|
sqlite3_prepare_v2(database, sql, -1, statement, NULL) == SQLITE_OK
|
|
? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static bool size_column(sqlite3_stmt *statement, int column, size_t *output)
|
|
{
|
|
sqlite3_int64 value = sqlite3_column_int64(statement, column);
|
|
if (value < 0 || (uint64_t)value > (uint64_t)SIZE_MAX) return false;
|
|
*output = (size_t)value;
|
|
return true;
|
|
}
|
|
|
|
static bool u64_column(sqlite3_stmt *statement, int column, uint64_t *output)
|
|
{
|
|
sqlite3_int64 value = sqlite3_column_int64(statement, column);
|
|
if (value < 0) return false;
|
|
*output = (uint64_t)value;
|
|
return true;
|
|
}
|
|
|
|
static bool copy_text(sqlite3_stmt *statement, int column, char *output,
|
|
size_t capacity)
|
|
{
|
|
const unsigned char *value = sqlite3_column_text(statement, column);
|
|
int bytes = sqlite3_column_bytes(statement, column);
|
|
if (output == NULL || capacity == 0U || value == NULL || bytes < 0 ||
|
|
(size_t)bytes >= capacity) return false;
|
|
(void)memcpy(output, value, (size_t)bytes);
|
|
output[bytes] = '\0';
|
|
return true;
|
|
}
|
|
|
|
static TrainlogStatus bind_window(sqlite3_stmt *statement, int64_t start,
|
|
int64_t now)
|
|
{
|
|
if (sqlite3_bind_int64(statement, 1, start) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 2, now) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
|
|
static bool local_date_timestamp(const char *date, int64_t *output)
|
|
{
|
|
char timestamp[32];
|
|
TrainlogTimestampKey key;
|
|
TrainlogTimestampKey unix_epoch;
|
|
int written;
|
|
if (date == NULL || output == NULL || strlen(date) != 10U) return false;
|
|
written = snprintf(timestamp, sizeof(timestamp), "%sT00:00:00Z", date);
|
|
if (written != 20 || !trainlog_timestamp_parse(timestamp, 20U, &key) ||
|
|
!trainlog_timestamp_parse("1970-01-01T00:00:00Z", 20U, &unix_epoch))
|
|
return false;
|
|
/* TrainlogTimestampKey uses a proleptic year-1 epoch for ordering. Chart
|
|
* coordinates use Unix seconds, so the epochs must never be conflated. */
|
|
*output = key.utc_second - unix_epoch.utc_second;
|
|
return true;
|
|
}
|
|
|
|
static bool current_local_week_timestamp(int64_t now, int64_t *output)
|
|
{
|
|
time_t seconds = (time_t)now;
|
|
struct tm local;
|
|
char date[11];
|
|
int offset;
|
|
if ((int64_t)seconds != now || localtime_r(&seconds, &local) == NULL) return false;
|
|
offset = (local.tm_wday + 6) % 7;
|
|
local.tm_mday -= offset;
|
|
local.tm_hour = 12; local.tm_min = 0; local.tm_sec = 0;
|
|
if (mktime(&local) == (time_t)-1 || strftime(date, sizeof(date), "%Y-%m-%d", &local) != 10U)
|
|
return false;
|
|
return local_date_timestamp(date, output);
|
|
}
|
|
|
|
static bool current_local_date(int64_t now, char output[11],
|
|
int64_t *timestamp)
|
|
{
|
|
time_t seconds = (time_t)now;
|
|
struct tm local;
|
|
if ((int64_t)seconds != now || localtime_r(&seconds, &local) == NULL ||
|
|
strftime(output, 11U, "%Y-%m-%d", &local) != 10U)
|
|
return false;
|
|
return local_date_timestamp(output, timestamp);
|
|
}
|
|
|
|
static bool calendar_window_start(TrainlogStatisticsWindow window,
|
|
int64_t now, int64_t *output)
|
|
{
|
|
char date[11];
|
|
int64_t ignored;
|
|
if (output == NULL) return false;
|
|
if (window == TRAINLOG_STATISTICS_ALL) {
|
|
*output = INT64_MIN;
|
|
return true;
|
|
}
|
|
if (window == TRAINLOG_STATISTICS_7_DAYS)
|
|
return current_local_week_timestamp(now, output);
|
|
if (!current_local_date(now, date, &ignored)) return false;
|
|
date[8] = '0';
|
|
date[9] = '1';
|
|
return local_date_timestamp(date, output);
|
|
}
|
|
|
|
static const char *actual_occurrences_cte(void)
|
|
{
|
|
return "WITH actual_occurrences AS ("
|
|
"SELECT se.id,se.session_row_id,se.exercise_row_id,se.entry_id,"
|
|
"se.load_mode,se.target_sets,se.target_reps,se.target_weight_kg "
|
|
"FROM session_exercises se WHERE EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) "
|
|
"OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) "
|
|
"OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)),"
|
|
"actual_sessions AS (SELECT DISTINCT s.id,s.session_id,s.started_at,s.ended_at "
|
|
"FROM sessions s JOIN actual_occurrences ao ON ao.session_row_id=s.id) ";
|
|
}
|
|
|
|
static TrainlogStatus load_summary_row(TrainlogDatabase *database,
|
|
int64_t start, int64_t now, TrainlogStatisticsSummary *output)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
char sql[8192];
|
|
int written = snprintf(sql, sizeof(sql),
|
|
"%s SELECT "
|
|
"(SELECT COUNT(*) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(DISTINCT ao.exercise_row_id) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(*) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(*) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM(p.reps),0) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE p.reps IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM(COALESCE(p.duration_seconds,0)),0) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2) + "
|
|
"(SELECT COALESCE(SUM(c.duration_seconds),0) FROM continuous_activity c JOIN actual_occurrences ao ON ao.id=c.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM(unixepoch(s.ended_at)-unixepoch(s.started_at)),0) FROM actual_sessions s WHERE s.ended_at IS NOT NULL AND unixepoch(s.ended_at)>=unixepoch(s.started_at) AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(*) FROM actual_sessions s WHERE s.ended_at IS NOT NULL AND unixepoch(s.ended_at)>=unixepoch(s.started_at) AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM(p.reps*p.weight_kg),0.0) FROM performed_sets p JOIN actual_occurrences ao ON ao.id=p.session_exercise_row_id JOIN sessions s ON s.id=ao.session_row_id JOIN exercises e ON e.id=ao.exercise_row_id WHERE p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(DISTINCT date(s.started_at)) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(DISTINCT CAST(unixepoch(s.started_at)/604800 AS INTEGER)) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(MAX(unixepoch(s.started_at)),0) FROM actual_sessions s WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(*) FROM session_followups f JOIN sessions s ON s.id=f.session_row_id WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COUNT(*) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM(ao.target_sets),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM((SELECT COUNT(*) FROM performed_sets p WHERE p.session_exercise_row_id=ao.id)),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM(ao.target_sets*ao.target_reps),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND ao.target_reps IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2),"
|
|
"(SELECT COALESCE(SUM((SELECT COALESCE(SUM(p.reps),0) FROM performed_sets p WHERE p.session_exercise_row_id=ao.id)),0) FROM actual_occurrences ao JOIN sessions s ON s.id=ao.session_row_id WHERE ao.target_sets IS NOT NULL AND ao.target_reps IS NOT NULL AND unixepoch(s.started_at) BETWEEN ?1 AND ?2)",
|
|
actual_occurrences_cte());
|
|
if (written < 0 || (size_t)written >= sizeof(sql) ||
|
|
prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
bind_window(statement, start, now) != TRAINLOG_STATUS_OK) goto fail;
|
|
if (sqlite3_step(statement) != SQLITE_ROW ||
|
|
!size_column(statement, 0, &output->sessions) ||
|
|
!size_column(statement, 1, &output->distinct_exercises) ||
|
|
!size_column(statement, 2, &output->occurrences) ||
|
|
!size_column(statement, 3, &output->sets) ||
|
|
!u64_column(statement, 4, &output->repetitions) ||
|
|
!u64_column(statement, 5, &output->activity_duration_seconds) ||
|
|
!u64_column(statement, 6, &output->session_duration_seconds) ||
|
|
!size_column(statement, 7, &output->sessions_with_duration) ||
|
|
!isfinite(sqlite3_column_double(statement, 8)) ||
|
|
!size_column(statement, 9, &output->active_days) ||
|
|
!size_column(statement, 10, &output->active_weeks) ||
|
|
!size_column(statement, 12, &output->immediate_feedback_count) ||
|
|
!size_column(statement, 13, &output->followup_count) ||
|
|
!size_column(statement, 14, &output->planned_actual_occurrences) ||
|
|
!u64_column(statement, 15, &output->planned_sets) ||
|
|
!u64_column(statement, 16, &output->actual_sets_for_plans) ||
|
|
!u64_column(statement, 17, &output->planned_repetitions) ||
|
|
!u64_column(statement, 18, &output->actual_repetitions_for_plans))
|
|
goto fail;
|
|
output->loaded_volume_kg = sqlite3_column_double(statement, 8);
|
|
output->latest_session_timestamp = sqlite3_column_int64(statement, 11);
|
|
if (sqlite3_finalize(statement) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
statement = NULL;
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static bool month_key_parse(const char *month, int64_t *key)
|
|
{
|
|
int year;
|
|
int number;
|
|
char trailing;
|
|
if (month == NULL || key == NULL ||
|
|
sscanf(month, "%d-%d%c", &year, &number, &trailing) != 2 ||
|
|
year < 1 || number < 1 || number > 12)
|
|
return false;
|
|
*key = (int64_t)year * INT64_C(12) + (int64_t)(number - 1);
|
|
return true;
|
|
}
|
|
|
|
static bool month_key_date(int64_t key, char output[11])
|
|
{
|
|
int64_t year;
|
|
int64_t month;
|
|
int written;
|
|
if (key < INT64_C(12)) return false;
|
|
year = key / INT64_C(12);
|
|
month = key % INT64_C(12) + 1;
|
|
if (year > 9999) return false;
|
|
written = snprintf(output, 11U, "%04lld-%02lld-01",
|
|
(long long)year, (long long)month);
|
|
return written == 10;
|
|
}
|
|
|
|
static bool format_month_label(int64_t key, char *output, size_t capacity)
|
|
{
|
|
static const char *const names[12] = {
|
|
"janv.", "févr.", "mars", "avr.", "mai", "juin",
|
|
"juil.", "août", "sept.", "oct.", "nov.", "déc."
|
|
};
|
|
int64_t year = key / INT64_C(12);
|
|
int64_t month = key % INT64_C(12);
|
|
int written;
|
|
if (output == NULL || capacity == 0U || month < 0 || month >= 12 ||
|
|
year < 1 || year > 9999) return false;
|
|
written = snprintf(output, capacity, "%s %04lld", names[month],
|
|
(long long)year);
|
|
return written >= 0 && (size_t)written < capacity;
|
|
}
|
|
|
|
static TrainlogStatus load_month_buckets(TrainlogDatabase *database,
|
|
int64_t now, bool recent_only, TrainlogStatisticsSummary *output)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogStatisticsBucket active[TRAINLOG_STATISTICS_BUCKET_MAX];
|
|
int64_t active_keys[TRAINLOG_STATISTICS_BUCKET_MAX];
|
|
size_t active_count = 0U;
|
|
int64_t current_key;
|
|
int64_t first_key = INT64_MAX;
|
|
char current_date[11];
|
|
char current_month[8];
|
|
int64_t ignored_timestamp;
|
|
char sql[4096];
|
|
int written = snprintf(sql, sizeof(sql),
|
|
"%s, months AS (SELECT substr(s.started_at,1,7) month,"
|
|
"COUNT(DISTINCT s.id) sessions,COUNT(p.id) sets,COALESCE(SUM(p.reps),0) reps,"
|
|
"COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0) volume "
|
|
"FROM actual_sessions s LEFT JOIN actual_occurrences ao ON ao.session_row_id=s.id "
|
|
"LEFT JOIN exercises e ON e.id=ao.exercise_row_id LEFT JOIN performed_sets p ON p.session_exercise_row_id=ao.id "
|
|
"WHERE unixepoch(s.started_at)<=?1 GROUP BY month) "
|
|
"SELECT month,sessions,sets,reps,volume,"
|
|
"(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises sf ON sf.id=f.session_exercise_row_id JOIN sessions ss ON ss.id=sf.session_row_id WHERE substr(ss.started_at,1,7)=months.month AND unixepoch(ss.started_at)<=?1) "
|
|
"FROM months ORDER BY month DESC LIMIT %u;",
|
|
actual_occurrences_cte(), TRAINLOG_STATISTICS_BUCKET_MAX + 1U);
|
|
int step;
|
|
if (!current_local_date(now, current_date, &ignored_timestamp))
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
(void)memcpy(current_month, current_date, 7U);
|
|
current_month[7] = '\0';
|
|
if (!month_key_parse(current_month, ¤t_key))
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
if (written < 0 || (size_t)written >= sizeof(sql) ||
|
|
prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_int64(statement, 1, now) != SQLITE_OK) goto fail;
|
|
output->buckets_partial = false;
|
|
output->bucket_kind = TRAINLOG_STATISTICS_BUCKET_CALENDAR_MONTH;
|
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogStatisticsBucket *bucket;
|
|
const char *month = (const char *)sqlite3_column_text(statement, 0);
|
|
int64_t key;
|
|
if (!month_key_parse(month, &key)) goto fail;
|
|
if (recent_only && (key < current_key - 3 || key > current_key))
|
|
continue;
|
|
if (active_count == TRAINLOG_STATISTICS_BUCKET_MAX) {
|
|
output->buckets_partial = true; continue;
|
|
}
|
|
bucket = &active[active_count];
|
|
(void)memset(bucket, 0, sizeof(*bucket));
|
|
active_keys[active_count++] = key;
|
|
if (!size_column(statement, 1, &bucket->sessions) ||
|
|
!size_column(statement, 2, &bucket->sets) ||
|
|
!u64_column(statement, 3, &bucket->repetitions) ||
|
|
!isfinite(sqlite3_column_double(statement, 4)) ||
|
|
!size_column(statement, 5, &bucket->immediate_feedback_count)) goto fail;
|
|
bucket->loaded_volume_kg = sqlite3_column_double(statement, 4);
|
|
if (key < first_key) first_key = key;
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
output->bucket_count = 0U;
|
|
if (first_key == INT64_MAX) return TRAINLOG_STATUS_OK;
|
|
if (recent_only && first_key < current_key - 3) first_key = current_key - 3;
|
|
if (current_key - first_key + 1 >
|
|
(int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) {
|
|
first_key = current_key -
|
|
((int64_t)TRAINLOG_STATISTICS_BUCKET_MAX - 1);
|
|
output->buckets_partial = true;
|
|
}
|
|
/* INVARIANT: Gregorian month keys are consecutive and non-overlapping.
|
|
* Filling every key preserves recorded inactivity and naturally handles
|
|
* February, leap years and year boundaries without day arithmetic. */
|
|
for (int64_t key = first_key; key <= current_key; ++key) {
|
|
TrainlogStatisticsBucket *bucket =
|
|
&output->buckets[output->bucket_count++];
|
|
char start_date[11];
|
|
char end_date[11];
|
|
(void)memset(bucket, 0, sizeof(*bucket));
|
|
if (!month_key_date(key, start_date) ||
|
|
!month_key_date(key + 1, end_date) ||
|
|
!local_date_timestamp(start_date, &bucket->timestamp) ||
|
|
!local_date_timestamp(end_date, &bucket->end_timestamp) ||
|
|
!format_month_label(key, bucket->label, sizeof(bucket->label)))
|
|
goto fail;
|
|
for (size_t index = 0U; index < active_count; ++index)
|
|
if (active_keys[index] == key) {
|
|
int64_t timestamp = bucket->timestamp;
|
|
int64_t end_timestamp = bucket->end_timestamp;
|
|
char label[sizeof(bucket->label)];
|
|
(void)memcpy(label, bucket->label, sizeof(label));
|
|
*bucket = active[index];
|
|
bucket->timestamp = timestamp;
|
|
bucket->end_timestamp = end_timestamp;
|
|
(void)memcpy(bucket->label, label, sizeof(label));
|
|
break;
|
|
}
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus load_buckets(TrainlogDatabase *database,
|
|
TrainlogStatisticsWindow window, int64_t now,
|
|
TrainlogStatisticsSummary *output)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
if (window == TRAINLOG_STATISTICS_30_DAYS)
|
|
return load_month_buckets(database, now, true, output);
|
|
output->bucket_kind = TRAINLOG_STATISTICS_BUCKET_7_DAYS;
|
|
char sql[4096];
|
|
int written = snprintf(sql, sizeof(sql),
|
|
"%s, weeks AS (SELECT date(substr(s.started_at,1,10),printf('-%%d days',(CAST(strftime('%%w',substr(s.started_at,1,10)) AS INTEGER)+6)%%7)) week,"
|
|
"COUNT(DISTINCT s.id) sessions,COUNT(p.id) sets,COALESCE(SUM(p.reps),0) reps,"
|
|
"COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND ao.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0) volume "
|
|
"FROM actual_sessions s LEFT JOIN actual_occurrences ao ON ao.session_row_id=s.id "
|
|
"LEFT JOIN exercises e ON e.id=ao.exercise_row_id LEFT JOIN performed_sets p ON p.session_exercise_row_id=ao.id "
|
|
"WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2 GROUP BY week) "
|
|
"SELECT week,sessions,sets,reps,volume,"
|
|
"(SELECT COUNT(*) FROM exercise_feedback f JOIN session_exercises sf ON sf.id=f.session_exercise_row_id JOIN sessions ss ON ss.id=sf.session_row_id WHERE date(substr(ss.started_at,1,10),printf('-%%d days',(CAST(strftime('%%w',substr(ss.started_at,1,10)) AS INTEGER)+6)%%7))=weeks.week AND unixepoch(ss.started_at) BETWEEN ?1 AND ?2) "
|
|
"FROM weeks ORDER BY week DESC LIMIT %u;",
|
|
actual_occurrences_cte(), TRAINLOG_STATISTICS_BUCKET_MAX + 1U);
|
|
if (written < 0 || (size_t)written >= sizeof(sql) ||
|
|
prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
bind_window(statement, INT64_MIN, now) != TRAINLOG_STATUS_OK) goto fail;
|
|
int step;
|
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogStatisticsBucket *bucket;
|
|
const char *week;
|
|
time_t seconds;
|
|
struct tm date;
|
|
if (output->bucket_count == TRAINLOG_STATISTICS_BUCKET_MAX) {
|
|
output->buckets_partial = true;
|
|
break;
|
|
}
|
|
bucket = &output->buckets[output->bucket_count++];
|
|
week = (const char *)sqlite3_column_text(statement, 0);
|
|
if (!local_date_timestamp(week, &bucket->timestamp) ||
|
|
!size_column(statement, 1, &bucket->sessions) ||
|
|
!size_column(statement, 2, &bucket->sets) ||
|
|
!u64_column(statement, 3, &bucket->repetitions) ||
|
|
!isfinite(sqlite3_column_double(statement, 4)) ||
|
|
!size_column(statement, 5, &bucket->immediate_feedback_count)) goto fail;
|
|
bucket->loaded_volume_kg = sqlite3_column_double(statement, 4);
|
|
seconds = (time_t)bucket->timestamp;
|
|
if ((int64_t)seconds != bucket->timestamp ||
|
|
gmtime_r(&seconds, &date) == NULL ||
|
|
strftime(bucket->label, sizeof(bucket->label), "%d/%m", &date) == 0U)
|
|
(void)snprintf(bucket->label, sizeof(bucket->label), "semaine");
|
|
}
|
|
if (step != SQLITE_DONE && !output->buckets_partial) goto fail;
|
|
if (sqlite3_finalize(statement) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
statement = NULL;
|
|
/* SQL reads newest-first for bounded history; charts require chronology. */
|
|
for (size_t left = 0U, right = output->bucket_count;
|
|
left < right && left < --right; ++left) {
|
|
TrainlogStatisticsBucket copy = output->buckets[left];
|
|
output->buckets[left] = output->buckets[right];
|
|
output->buckets[right] = copy;
|
|
}
|
|
/* WHY: active-row count alone misses sparse but very long histories. The
|
|
* all-history chart chooses one scale from total duration, so it neither
|
|
* drops an old sparse bucket nor mixes bucket sizes in a single graph. */
|
|
if (window == TRAINLOG_STATISTICS_ALL && output->bucket_count > 0U) {
|
|
int64_t current_week;
|
|
int64_t week_span;
|
|
if (!current_local_week_timestamp(now, ¤t_week)) goto fail;
|
|
week_span = (current_week - output->buckets[0].timestamp) /
|
|
SECONDS_PER_WEEK + 1;
|
|
if (output->buckets_partial ||
|
|
week_span > (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX)
|
|
return load_month_buckets(database, now, false, output);
|
|
}
|
|
if (output->bucket_count > 0U) {
|
|
TrainlogStatisticsBucket filled[TRAINLOG_STATISTICS_BUCKET_MAX];
|
|
int64_t first_week = output->buckets[0].timestamp;
|
|
int64_t last_week;
|
|
int64_t span;
|
|
size_t filled_count = 0U;
|
|
size_t source = 0U;
|
|
if (!current_local_week_timestamp(now, &last_week)) goto fail;
|
|
if (window == TRAINLOG_STATISTICS_7_DAYS) {
|
|
int64_t comparison_start = last_week - INT64_C(3) * SECONDS_PER_WEEK;
|
|
if (first_week < comparison_start) first_week = comparison_start;
|
|
}
|
|
span = (last_week - first_week) / SECONDS_PER_WEEK + 1;
|
|
if (span > (int64_t)TRAINLOG_STATISTICS_BUCKET_MAX) {
|
|
first_week = last_week -
|
|
((int64_t)TRAINLOG_STATISTICS_BUCKET_MAX - 1) * SECONDS_PER_WEEK;
|
|
output->buckets_partial = true;
|
|
}
|
|
/* INVARIANT: weekly facts include explicit zero weeks. Connecting only
|
|
* active weeks would visually invent activity inside a chronological gap. */
|
|
for (int64_t week = first_week; week <= last_week;
|
|
week += SECONDS_PER_WEEK) {
|
|
int64_t timestamp = week;
|
|
time_t seconds = (time_t)timestamp;
|
|
struct tm date;
|
|
while (source < output->bucket_count &&
|
|
output->buckets[source].timestamp < timestamp) ++source;
|
|
(void)memset(&filled[filled_count], 0, sizeof(filled[filled_count]));
|
|
filled[filled_count].timestamp = timestamp;
|
|
filled[filled_count].end_timestamp = timestamp + SECONDS_PER_WEEK;
|
|
if (source < output->bucket_count &&
|
|
output->buckets[source].timestamp == timestamp) {
|
|
int64_t end_timestamp = filled[filled_count].end_timestamp;
|
|
filled[filled_count] = output->buckets[source];
|
|
filled[filled_count].end_timestamp = end_timestamp;
|
|
}
|
|
if ((int64_t)seconds == timestamp && gmtime_r(&seconds, &date) != NULL) {
|
|
char start_label[6];
|
|
char end_label[6];
|
|
time_t end_seconds = seconds + (time_t)(INT64_C(6) * SECONDS_PER_DAY);
|
|
struct tm end;
|
|
if (gmtime_r(&end_seconds, &end) != NULL &&
|
|
strftime(start_label, sizeof(start_label), "%d/%m", &date) == 5U &&
|
|
strftime(end_label, sizeof(end_label), "%d/%m", &end) == 5U)
|
|
(void)snprintf(filled[filled_count].label,
|
|
sizeof(filled[filled_count].label), "%s-%s",
|
|
start_label, end_label);
|
|
}
|
|
++filled_count;
|
|
}
|
|
(void)memcpy(output->buckets, filled,
|
|
filled_count * sizeof(output->buckets[0]));
|
|
output->bucket_count = filled_count;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus load_top_feedback(TrainlogDatabase *database,
|
|
int64_t start, int64_t now, TrainlogStatisticsSummary *output)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
const char *sql =
|
|
"SELECT e.name,COUNT(*) amount FROM exercise_feedback f "
|
|
"JOIN session_exercises se ON se.id=f.session_exercise_row_id "
|
|
"JOIN sessions s ON s.id=se.session_row_id "
|
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"WHERE unixepoch(s.started_at) BETWEEN ?1 AND ?2 "
|
|
"GROUP BY e.id ORDER BY amount DESC,e.name COLLATE NOCASE,e.exercise_id LIMIT 1;";
|
|
int step;
|
|
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
bind_window(statement, start, now) != TRAINLOG_STATUS_OK) goto fail;
|
|
step = sqlite3_step(statement);
|
|
if (step == SQLITE_ROW &&
|
|
(!copy_text(statement, 0, output->top_feedback_exercise,
|
|
sizeof(output->top_feedback_exercise)) ||
|
|
!size_column(statement, 1, &output->top_feedback_count))) goto fail;
|
|
if (step != SQLITE_ROW && step != SQLITE_DONE) goto fail;
|
|
if (sqlite3_finalize(statement) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_statistics_load_summary(TrainlogDatabase *database,
|
|
TrainlogStatisticsWindow window, int64_t now_utc,
|
|
TrainlogStatisticsSummary *output)
|
|
{
|
|
TrainlogStatus status;
|
|
int64_t start;
|
|
if (database == NULL || database->connection == NULL || output == NULL ||
|
|
window < TRAINLOG_STATISTICS_7_DAYS || window > TRAINLOG_STATISTICS_ALL)
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
(void)memset(output, 0, sizeof(*output));
|
|
if (!calendar_window_start(window, now_utc, &start))
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
status = trainlog_database_read_snapshot_begin(database);
|
|
if (status != TRAINLOG_STATUS_OK) return status;
|
|
status = load_summary_row(database, start, now_utc, output);
|
|
if (status == TRAINLOG_STATUS_OK && output->latest_session_timestamp > 0 &&
|
|
output->latest_session_timestamp <= now_utc) {
|
|
uint64_t days = (uint64_t)(now_utc - output->latest_session_timestamp) /
|
|
(uint64_t)SECONDS_PER_DAY;
|
|
if (days > (uint64_t)SIZE_MAX) status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
else output->days_since_last_session = (size_t)days;
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK)
|
|
status = load_top_feedback(database, start, now_utc, output);
|
|
if (status == TRAINLOG_STATUS_OK)
|
|
status = load_buckets(database, window, now_utc, output);
|
|
if (trainlog_database_read_snapshot_end(database,
|
|
status == TRAINLOG_STATUS_OK) != TRAINLOG_STATUS_OK)
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return status;
|
|
}
|
|
|
|
TrainlogStatus trainlog_statistics_load_zones(TrainlogDatabase *database,
|
|
int64_t now_utc, TrainlogZoneStatistics *output, size_t capacity,
|
|
size_t *output_count, bool *output_partial)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
const char *sql =
|
|
"WITH actual_occurrences AS (SELECT se.id,se.session_row_id,se.exercise_row_id FROM session_exercises se "
|
|
"WHERE EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) "
|
|
"SELECT z.zone_id,COUNT(DISTINCT s.id),"
|
|
"COUNT(DISTINCT CASE WHEN z.role='primary' AND unixepoch(s.started_at)>=?1 THEN s.id END),"
|
|
"COUNT(DISTINCT CASE WHEN z.role='secondary' AND unixepoch(s.started_at)>=?1 THEN s.id END),"
|
|
"COUNT(DISTINCT CASE WHEN z.role='primary' AND unixepoch(s.started_at)>=?2 THEN s.id END),"
|
|
"COUNT(DISTINCT CASE WHEN z.role='secondary' AND unixepoch(s.started_at)>=?2 THEN s.id END),"
|
|
"COUNT(DISTINCT ao.exercise_row_id),"
|
|
"MAX(CASE WHEN z.role='primary' THEN s.started_at END),"
|
|
"MAX(CASE WHEN z.role='secondary' THEN s.started_at END) "
|
|
"FROM exercise_body_zones z JOIN actual_occurrences ao ON ao.exercise_row_id=z.exercise_row_id "
|
|
"JOIN sessions s ON s.id=ao.session_row_id WHERE unixepoch(s.started_at)<=?3 "
|
|
"GROUP BY z.zone_id ORDER BY z.zone_id;";
|
|
TrainlogStatus status = TRAINLOG_STATUS_OK;
|
|
int step;
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
output_partial == NULL || (capacity > 0U && output == NULL))
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
*output_count = 0U; *output_partial = false;
|
|
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_int64(statement, 1, now_utc - INT64_C(7) * SECONDS_PER_DAY) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 2, now_utc - INT64_C(30) * SECONDS_PER_DAY) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 3, now_utc) != SQLITE_OK) goto fail;
|
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const unsigned char *zone = sqlite3_column_text(statement, 0);
|
|
size_t index = *output_count;
|
|
TrainlogZoneStatistics *value;
|
|
if (zone == NULL) goto fail;
|
|
if (index == capacity) { *output_partial = true; continue; }
|
|
value = &output[index];
|
|
(void)memset(value, 0, sizeof(*value));
|
|
if (!copy_text(statement, 0, value->zone_id,
|
|
sizeof(value->zone_id))) goto fail;
|
|
{
|
|
const unsigned char *latest_primary = sqlite3_column_text(statement, 7);
|
|
const unsigned char *latest_secondary = sqlite3_column_text(statement, 8);
|
|
if (!size_column(statement, 1, &value->sessions_any) ||
|
|
!size_column(statement, 2, &value->primary_7) ||
|
|
!size_column(statement, 3, &value->secondary_7) ||
|
|
!size_column(statement, 4, &value->primary_30) ||
|
|
!size_column(statement, 5, &value->secondary_30) ||
|
|
!size_column(statement, 6, &value->distinct_exercises)) goto fail;
|
|
if (latest_primary != NULL && !copy_text(statement, 7,
|
|
value->latest_primary, sizeof(value->latest_primary))) goto fail;
|
|
if (latest_secondary != NULL && !copy_text(statement, 8,
|
|
value->latest_secondary, sizeof(value->latest_secondary))) goto fail;
|
|
}
|
|
++(*output_count);
|
|
}
|
|
if (step != SQLITE_DONE) goto fail;
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return status;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static bool append_point(TrainlogExerciseStatistics *output,
|
|
TrainlogExerciseSeriesKind kind, int64_t timestamp, const char *label,
|
|
double value)
|
|
{
|
|
size_t index = output->series_count[kind];
|
|
TrainlogStatisticPoint *point;
|
|
if (!isfinite(value)) return false;
|
|
if (index == TRAINLOG_STATISTICS_SERIES_MAX) {
|
|
output->series_partial[kind] = true;
|
|
return true;
|
|
}
|
|
point = &output->series[kind][index];
|
|
point->timestamp = timestamp; point->value = value;
|
|
if (snprintf(point->timestamp_label, sizeof(point->timestamp_label), "%s",
|
|
label) < 0) return false;
|
|
output->series_count[kind] = index + 1U;
|
|
return true;
|
|
}
|
|
|
|
TrainlogStatus trainlog_statistics_load_exercise(TrainlogDatabase *database,
|
|
const char *exercise_id, int64_t now_utc, TrainlogExerciseStatistics *output)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
TrainlogStatus status = TRAINLOG_STATUS_OK;
|
|
const char *profile_sql = "SELECT name,tracking_mode,recording_mode,COALESCE(load_semantics,'') FROM exercises WHERE exercise_id=?1;";
|
|
const char *series_sql =
|
|
"SELECT s.started_at,unixepoch(s.started_at),se.id,"
|
|
"COUNT(p.id),COALESCE(SUM(p.reps),0),COALESCE(SUM(p.duration_seconds),0)+COALESCE(c.duration_seconds,0),"
|
|
"MAX(CASE WHEN p.weight_kg IS NOT NULL THEN p.weight_kg END),"
|
|
"COALESCE(SUM(CASE WHEN p.reps IS NOT NULL AND p.weight_kg IS NOT NULL AND se.load_mode='external' AND e.load_semantics='external' THEN p.reps*p.weight_kg ELSE 0 END),0.0),"
|
|
"m.max_weight_kg,se.target_sets,se.target_reps,se.target_weight_kg,"
|
|
"(SELECT COUNT(*) FROM exercise_feedback f WHERE f.session_exercise_row_id=se.id) "
|
|
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
|
"LEFT JOIN performed_sets p ON p.session_exercise_row_id=se.id LEFT JOIN continuous_activity c ON c.session_exercise_row_id=se.id "
|
|
"LEFT JOIN max_results m ON m.session_exercise_row_id=se.id WHERE e.exercise_id=?1 "
|
|
"AND (p.id IS NOT NULL OR c.id IS NOT NULL OR m.max_weight_kg IS NOT NULL) AND unixepoch(s.started_at)<=?2 "
|
|
"GROUP BY s.id,se.id ORDER BY unixepoch(s.started_at),s.session_id,se.entry_id;";
|
|
int step;
|
|
if (database == NULL || database->connection == NULL || exercise_id == NULL ||
|
|
exercise_id[0] == '\0' || output == NULL)
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
(void)memset(output, 0, sizeof(*output));
|
|
(void)snprintf(output->exercise_id, sizeof(output->exercise_id), "%s", exercise_id);
|
|
if (prepare(database->connection, profile_sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK)
|
|
goto fail;
|
|
if (sqlite3_step(statement) != SQLITE_ROW ||
|
|
!copy_text(statement, 0, output->name, sizeof(output->name))) {
|
|
status = TRAINLOG_STATUS_NOT_FOUND; goto done;
|
|
}
|
|
{
|
|
const char *tracking = (const char *)sqlite3_column_text(statement, 1);
|
|
const char *recording = (const char *)sqlite3_column_text(statement, 2);
|
|
if (tracking == NULL || recording == NULL ||
|
|
!copy_text(statement, 3, output->load_semantics,
|
|
sizeof(output->load_semantics))) goto fail;
|
|
output->tracking_mode = strcmp(tracking, "duration") == 0
|
|
? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS;
|
|
output->recording_mode = strcmp(recording, "continuous") == 0
|
|
? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS;
|
|
}
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto fail; }
|
|
statement = NULL;
|
|
if (prepare(database->connection, series_sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 2, now_utc) != SQLITE_OK) goto fail;
|
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
const unsigned char *at = sqlite3_column_text(statement, 0);
|
|
int64_t timestamp = sqlite3_column_int64(statement, 1);
|
|
size_t sets;
|
|
uint64_t reps, duration;
|
|
double volume = sqlite3_column_double(statement, 7);
|
|
if (at == NULL || !size_column(statement, 3, &sets) ||
|
|
!u64_column(statement, 4, &reps) || !u64_column(statement, 5, &duration) ||
|
|
!isfinite(volume)) goto fail;
|
|
++output->occurrences;
|
|
++output->sessions; /* corrected to distinct below after ordered scan */
|
|
if (sets > SIZE_MAX - output->sets || reps > UINT64_MAX - output->repetitions ||
|
|
duration > UINT64_MAX - output->duration_seconds) goto fail;
|
|
output->sets += sets; output->repetitions += reps;
|
|
output->duration_seconds += duration; output->loaded_volume_kg += volume;
|
|
if (output->first_at[0] == '\0')
|
|
(void)snprintf(output->first_at, sizeof(output->first_at), "%s", at);
|
|
(void)snprintf(output->last_at, sizeof(output->last_at), "%s", at);
|
|
if (sqlite3_column_type(statement, 6) != SQLITE_NULL) {
|
|
double load = sqlite3_column_double(statement, 6);
|
|
if (!isfinite(load)) goto fail;
|
|
if (!output->has_highest_load || load > output->highest_load_kg) {
|
|
output->highest_load_kg = load; output->has_highest_load = true;
|
|
}
|
|
if (!append_point(output, TRAINLOG_EXERCISE_SERIES_LOAD,
|
|
timestamp, (const char *)at, load)) goto fail;
|
|
}
|
|
if (sets > 0U && !append_point(output, TRAINLOG_EXERCISE_SERIES_SETS,
|
|
timestamp, (const char *)at, (double)sets)) goto fail;
|
|
if (reps > 0U && !append_point(output, TRAINLOG_EXERCISE_SERIES_REPETITIONS,
|
|
timestamp, (const char *)at, (double)reps)) goto fail;
|
|
if (volume > 0.0 && !append_point(output, TRAINLOG_EXERCISE_SERIES_VOLUME,
|
|
timestamp, (const char *)at, volume)) goto fail;
|
|
if (sqlite3_column_type(statement, 8) != SQLITE_NULL) {
|
|
double maximum = sqlite3_column_double(statement, 8);
|
|
if (!isfinite(maximum) || maximum <= 0.0) goto fail;
|
|
if (output->has_latest_max) {
|
|
output->previous_max_kg = output->latest_max_kg;
|
|
output->has_previous_max = true;
|
|
}
|
|
output->latest_max_kg = maximum; output->has_latest_max = true;
|
|
if (output->max_count == 0U || maximum > output->best_max_kg)
|
|
output->best_max_kg = maximum;
|
|
++output->max_count;
|
|
(void)snprintf(output->latest_max_at, sizeof(output->latest_max_at),
|
|
"%s", at);
|
|
if (!append_point(output, TRAINLOG_EXERCISE_SERIES_MAX,
|
|
timestamp, (const char *)at, maximum)) goto fail;
|
|
}
|
|
if (sqlite3_column_type(statement, 9) != SQLITE_NULL) {
|
|
uint64_t target_sets = (uint64_t)sqlite3_column_int64(statement, 9);
|
|
if (target_sets > UINT64_MAX - output->planned_sets ||
|
|
(uint64_t)sets > UINT64_MAX - output->actual_sets_for_plans)
|
|
goto fail;
|
|
output->planned_sets += target_sets;
|
|
output->actual_sets_for_plans += (uint64_t)sets;
|
|
if (sqlite3_column_type(statement, 10) != SQLITE_NULL) {
|
|
uint64_t target_reps = (uint64_t)sqlite3_column_int64(statement, 10);
|
|
if (target_reps != 0U && target_sets > UINT64_MAX / target_reps)
|
|
goto fail;
|
|
target_reps *= target_sets;
|
|
if (target_reps > UINT64_MAX - output->planned_repetitions ||
|
|
reps > UINT64_MAX - output->actual_repetitions_for_plans)
|
|
goto fail;
|
|
output->planned_repetitions += target_reps;
|
|
output->actual_repetitions_for_plans += reps;
|
|
}
|
|
if (sqlite3_column_type(statement, 11) != SQLITE_NULL &&
|
|
sqlite3_column_type(statement, 6) != SQLITE_NULL) {
|
|
double planned_weight = sqlite3_column_double(statement, 11);
|
|
double actual_weight = sqlite3_column_double(statement, 6);
|
|
if (!isfinite(planned_weight) || !isfinite(actual_weight)) goto fail;
|
|
output->latest_planned_weight_kg = planned_weight;
|
|
output->latest_actual_weight_kg = actual_weight;
|
|
output->has_latest_planned_actual_weight = true;
|
|
}
|
|
}
|
|
{
|
|
size_t feedback;
|
|
if (!size_column(statement, 12, &feedback) ||
|
|
feedback > SIZE_MAX - output->immediate_feedback_count) goto fail;
|
|
output->immediate_feedback_count += feedback;
|
|
}
|
|
}
|
|
if (step != SQLITE_DONE) goto fail;
|
|
/* Occurrences of one exercise can repeat inside a session. This aggregate
|
|
* query is chronological but not session-ID preserving, so obtain the exact
|
|
* distinct count in one constant supplementary query. */
|
|
if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto fail; }
|
|
statement = NULL;
|
|
if (prepare(database->connection,
|
|
"SELECT COUNT(DISTINCT CASE WHEN (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END),"
|
|
"COUNT(DISTINCT CASE WHEN unixepoch(s.started_at)>=?2-604800 AND (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END),"
|
|
"COUNT(DISTINCT CASE WHEN unixepoch(s.started_at)>=?2-2592000 AND (EXISTS(SELECT 1 FROM performed_sets pa WHERE pa.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM continuous_activity ca WHERE ca.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM max_results ma WHERE ma.session_exercise_row_id=se.id)) THEN s.id END),"
|
|
"(SELECT p2.reps FROM performed_sets p2 JOIN session_exercises se2 ON se2.id=p2.session_exercise_row_id JOIN sessions s2 ON s2.id=se2.session_row_id JOIN exercises e2 ON e2.id=se2.exercise_row_id WHERE e2.exercise_id=?1 AND p2.reps IS NOT NULL AND p2.weight_kg IS NOT NULL AND unixepoch(s2.started_at)<=?2 ORDER BY p2.reps DESC,p2.weight_kg DESC,s2.started_at DESC LIMIT 1),"
|
|
"(SELECT p2.weight_kg FROM performed_sets p2 JOIN session_exercises se2 ON se2.id=p2.session_exercise_row_id JOIN sessions s2 ON s2.id=se2.session_row_id JOIN exercises e2 ON e2.id=se2.exercise_row_id WHERE e2.exercise_id=?1 AND p2.reps IS NOT NULL AND p2.weight_kg IS NOT NULL AND unixepoch(s2.started_at)<=?2 ORDER BY p2.reps DESC,p2.weight_kg DESC,s2.started_at DESC LIMIT 1) "
|
|
"FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id WHERE e.exercise_id=?1 AND unixepoch(s.started_at)<=?2;",
|
|
&statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
|
sqlite3_bind_int64(statement, 2, now_utc) != SQLITE_OK ||
|
|
sqlite3_step(statement) != SQLITE_ROW ||
|
|
!size_column(statement, 0, &output->sessions) ||
|
|
!size_column(statement, 1, &output->frequency_7) ||
|
|
!size_column(statement, 2, &output->frequency_30)) goto fail;
|
|
if (sqlite3_column_type(statement, 3) != SQLITE_NULL) {
|
|
output->highest_repetitions = sqlite3_column_int(statement, 3);
|
|
output->has_repetitions_at_load = sqlite3_column_type(statement, 4) != SQLITE_NULL;
|
|
if (output->has_repetitions_at_load)
|
|
output->highest_repetitions_load_kg = sqlite3_column_double(statement, 4);
|
|
}
|
|
done:
|
|
if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK)
|
|
status = TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return status;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_statistics_list_maxima(TrainlogDatabase *database,
|
|
TrainlogMaxListItem *output, size_t capacity, size_t *output_count,
|
|
bool *output_partial)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
const char *sql =
|
|
"SELECT exercise_id,max_weight_kg,started_at FROM ("
|
|
"SELECT e.exercise_id,m.max_weight_kg,s.started_at,"
|
|
"ROW_NUMBER() OVER (PARTITION BY e.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC) rank "
|
|
"FROM max_results m JOIN session_exercises se ON se.id=m.session_exercise_row_id "
|
|
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id) "
|
|
"WHERE rank=1 ORDER BY started_at DESC,exercise_id LIMIT ?1;";
|
|
int step;
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
output_partial == NULL || (capacity > 0U && output == NULL) ||
|
|
capacity == SIZE_MAX) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
*output_count = 0U; *output_partial = false;
|
|
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK)
|
|
goto fail;
|
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogMaxListItem *item;
|
|
if (*output_count == capacity) { *output_partial = true; continue; }
|
|
item = &output[*output_count];
|
|
(void)memset(item, 0, sizeof(*item));
|
|
if (!copy_text(statement, 0, item->exercise_id, sizeof(item->exercise_id)) ||
|
|
!isfinite(sqlite3_column_double(statement, 1)) ||
|
|
!copy_text(statement, 2, item->latest_at, sizeof(item->latest_at))) goto fail;
|
|
item->latest_max_kg = sqlite3_column_double(statement, 1);
|
|
++(*output_count);
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_statistics_list_exercises(TrainlogDatabase *database,
|
|
TrainlogExerciseListStatistic *output, size_t capacity,
|
|
size_t *output_count, bool *output_partial)
|
|
{
|
|
sqlite3_stmt *statement = NULL;
|
|
char sql[2048];
|
|
int written = snprintf(sql, sizeof(sql),
|
|
"%s SELECT e.exercise_id,COUNT(*),MAX(s.started_at) FROM actual_occurrences ao "
|
|
"JOIN sessions s ON s.id=ao.session_row_id JOIN exercises e ON e.id=ao.exercise_row_id "
|
|
"GROUP BY e.id ORDER BY MAX(unixepoch(s.started_at)) DESC,e.exercise_id LIMIT ?1;",
|
|
actual_occurrences_cte());
|
|
int step;
|
|
if (database == NULL || database->connection == NULL || output_count == NULL ||
|
|
output_partial == NULL || (capacity > 0U && output == NULL) ||
|
|
capacity == SIZE_MAX || written < 0 || (size_t)written >= sizeof(sql))
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
*output_count = 0U; *output_partial = false;
|
|
if (prepare(database->connection, sql, &statement) != TRAINLOG_STATUS_OK ||
|
|
sqlite3_bind_int64(statement, 1, (sqlite3_int64)(capacity + 1U)) != SQLITE_OK)
|
|
goto fail;
|
|
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
|
TrainlogExerciseListStatistic *item;
|
|
if (*output_count == capacity) { *output_partial = true; continue; }
|
|
item = &output[*output_count];
|
|
(void)memset(item, 0, sizeof(*item));
|
|
if (!copy_text(statement, 0, item->exercise_id, sizeof(item->exercise_id)) ||
|
|
!size_column(statement, 1, &item->occurrences) ||
|
|
!copy_text(statement, 2, item->latest_at, sizeof(item->latest_at))) goto fail;
|
|
++(*output_count);
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK)
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (statement != NULL) (void)sqlite3_finalize(statement);
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|