425 lines
18 KiB
C
425 lines
18 KiB
C
#include "trainlog/web_dashboard.h"
|
|
|
|
#include <math.h>
|
|
#include <pwd.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
#include <sqlite3.h>
|
|
|
|
#include "trainlog/body_zone_catalog.h"
|
|
#include "database_internal.h"
|
|
#include "timestamp.h"
|
|
|
|
#define SECONDS_PER_DAY INT64_C(86400)
|
|
|
|
static bool copy_column(sqlite3_stmt *s, int column, char *out, size_t cap) {
|
|
const unsigned char *text;
|
|
int bytes;
|
|
if (sqlite3_column_type(s, column) != SQLITE_TEXT) {
|
|
return false;
|
|
}
|
|
text = sqlite3_column_text(s, column);
|
|
bytes = sqlite3_column_bytes(s, column);
|
|
if (text == NULL || bytes < 0 || (size_t)bytes >= cap) {
|
|
return false;
|
|
}
|
|
(void)memcpy(out, text, (size_t)bytes);
|
|
out[bytes] = '\0';
|
|
return true;
|
|
}
|
|
static bool read_size(sqlite3_stmt *s, int c, size_t *out) {
|
|
sqlite3_int64 v;
|
|
if (sqlite3_column_type(s, c) != SQLITE_INTEGER) {
|
|
return false;
|
|
}
|
|
v = sqlite3_column_int64(s, c);
|
|
if (v < 0 || (uint64_t)v > (uint64_t)SIZE_MAX) {
|
|
return false;
|
|
}
|
|
*out = (size_t)v;
|
|
return true;
|
|
}
|
|
static bool prepare(TrainlogDatabase *d, const char *sql, sqlite3_stmt **s) {
|
|
return sqlite3_prepare_v2(d->connection, sql, -1, s, NULL) == SQLITE_OK;
|
|
}
|
|
|
|
static bool format_generated(int64_t now, char out[TRAINLOG_TIMESTAMP_MAX + 1U]) {
|
|
time_t value = (time_t)now;
|
|
struct tm utc;
|
|
if ((int64_t)value != now || gmtime_r(&value, &utc) == NULL) {
|
|
return false;
|
|
}
|
|
return strftime(out, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
|
|
}
|
|
static bool format_local_date(time_t value, char out[11]) {
|
|
struct tm local;
|
|
return localtime_r(&value, &local) != NULL && strftime(out, 11U, "%Y-%m-%d", &local) == 10U;
|
|
}
|
|
static bool load_user(char out[TRAINLOG_NAME_MAX + 1U]) {
|
|
struct passwd value;
|
|
struct passwd *result = NULL;
|
|
char buffer[4096];
|
|
int rc = getpwuid_r(geteuid(), &value, buffer, sizeof(buffer), &result);
|
|
if (rc != 0 || result == NULL || result->pw_name == NULL || result->pw_name[0] == '\0' ||
|
|
strlen(result->pw_name) > TRAINLOG_NAME_MAX) {
|
|
return false;
|
|
}
|
|
(void)snprintf(out, TRAINLOG_NAME_MAX + 1U, "%s", result->pw_name);
|
|
return true;
|
|
}
|
|
|
|
static TrainlogStatus
|
|
load_activity(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
|
|
static const char SQL[] =
|
|
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(ps.id) FROM sessions s "
|
|
"LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN performed_sets ps ON "
|
|
"ps.session_exercise_row_id=se.id "
|
|
"WHERE unixepoch(s.started_at)<=?1 AND substr(s.started_at,1,10)>=?2 AND (EXISTS(SELECT 1 "
|
|
"FROM session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE "
|
|
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN "
|
|
"continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR "
|
|
"EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
|
|
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id)) GROUP BY "
|
|
"substr(s.started_at,1,10) ORDER BY 1;";
|
|
sqlite3_stmt *s = NULL;
|
|
int step;
|
|
size_t i;
|
|
time_t reference = (time_t)now;
|
|
struct tm current;
|
|
if ((int64_t)reference != now) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
if (localtime_r(&reference, ¤t) == NULL) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
out->activity_count = TRAINLOG_WEB_ACTIVITY_DAYS;
|
|
for (i = 0U; i < TRAINLOG_WEB_ACTIVITY_DAYS; ++i) {
|
|
struct tm day = current;
|
|
time_t normalized;
|
|
day.tm_mday -= (int)(TRAINLOG_WEB_ACTIVITY_DAYS - 1U - i);
|
|
day.tm_isdst = -1;
|
|
normalized = mktime(&day);
|
|
if (normalized == (time_t)-1 || !format_local_date(normalized, out->activity[i].date)) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
}
|
|
if (!prepare(d, SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK ||
|
|
sqlite3_bind_text(s, 2, out->activity[0].date, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
goto fail;
|
|
}
|
|
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
|
|
char date[11];
|
|
size_t sessions, sets;
|
|
if (!copy_column(s, 0, date, sizeof(date)) || !read_size(s, 1, &sessions) ||
|
|
!read_size(s, 2, &sets)) {
|
|
goto fail;
|
|
}
|
|
for (i = 0U; i < out->activity_count; ++i) {
|
|
if (strcmp(date, out->activity[i].date) == 0) {
|
|
out->activity[i].active = true;
|
|
out->activity[i].session_count = sessions;
|
|
out->activity[i].set_count = sets;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (s != NULL) {
|
|
(void)sqlite3_finalize(s);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus load_last(TrainlogDatabase *d, int64_t now, TrainlogWebLastSession *out) {
|
|
static const char SESSION_SQL[] =
|
|
"SELECT s.session_id,s.started_at,COALESCE(s.ended_at,''),COUNT(DISTINCT CASE WHEN ps.id "
|
|
"IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL THEN se.id "
|
|
"END),COUNT(DISTINCT ps.id),COUNT(DISTINCT ca.id),COUNT(DISTINCT "
|
|
"mr.session_exercise_row_id) FROM sessions s JOIN session_exercises se ON "
|
|
"se.session_row_id=s.id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
|
|
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id LEFT JOIN "
|
|
"max_results mr ON mr.session_exercise_row_id=se.id WHERE unixepoch(s.started_at)<=?1 AND "
|
|
"(ps.id IS NOT NULL OR ca.id IS NOT NULL OR mr.session_exercise_row_id IS NOT NULL) GROUP "
|
|
"BY s.id ORDER BY unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1;";
|
|
static const char ZONE_SQL[] =
|
|
"SELECT z.zone_id,COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN "
|
|
"session_exercises se ON se.session_row_id=s.id JOIN exercises e ON "
|
|
"e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND "
|
|
"z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE "
|
|
"s.session_id=?1 AND (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)) GROUP BY z.zone_id ORDER BY z.zone_id;";
|
|
sqlite3_stmt *s = NULL;
|
|
int step;
|
|
TrainlogTimestampKey start, end;
|
|
if (!prepare(d, SESSION_SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK) {
|
|
goto fail;
|
|
}
|
|
step = sqlite3_step(s);
|
|
if (step == SQLITE_DONE) {
|
|
(void)sqlite3_finalize(s);
|
|
return TRAINLOG_STATUS_OK;
|
|
}
|
|
if (step != SQLITE_ROW || !copy_column(s, 0, out->session_id, sizeof(out->session_id)) ||
|
|
!copy_column(s, 1, out->started_at, sizeof(out->started_at)) ||
|
|
!copy_column(s, 2, out->ended_at, sizeof(out->ended_at)) ||
|
|
!read_size(s, 3, &out->exercise_count) || !read_size(s, 4, &out->set_count) ||
|
|
!read_size(s, 5, &out->continuous_count) || !read_size(s, 6, &out->max_count)) {
|
|
goto fail;
|
|
}
|
|
out->available = true;
|
|
out->has_ended_at = out->ended_at[0] != '\0';
|
|
if (!trainlog_timestamp_parse(out->started_at, strlen(out->started_at), &start)) {
|
|
out->available = false;
|
|
goto invalid;
|
|
}
|
|
if (out->has_ended_at) {
|
|
if (!trainlog_timestamp_parse(out->ended_at, strlen(out->ended_at), &end) ||
|
|
end.utc_second < start.utc_second) {
|
|
goto invalid;
|
|
}
|
|
out->has_duration = true;
|
|
out->duration_seconds = (uint64_t)(end.utc_second - start.utc_second);
|
|
}
|
|
if (sqlite3_step(s) != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
s = NULL;
|
|
if (!prepare(d, ZONE_SQL, &s) ||
|
|
sqlite3_bind_text(s, 1, out->session_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
|
goto fail;
|
|
}
|
|
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
|
|
TrainlogWebWorkedZone *z;
|
|
const TrainlogBodyZone *catalog;
|
|
if (out->zone_count == TRAINLOG_WEB_ZONE_CAPACITY) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
z = &out->zones[out->zone_count];
|
|
if (!copy_column(s, 0, z->zone_id, sizeof(z->zone_id)) ||
|
|
!read_size(s, 1, &z->occurrence_count) || !read_size(s, 2, &z->set_count)) {
|
|
goto fail;
|
|
}
|
|
z->session_count = 1U;
|
|
catalog = trainlog_body_zone_catalog_lookup(z->zone_id);
|
|
if (catalog == NULL || strlen(catalog->display_name) > TRAINLOG_NAME_MAX) {
|
|
goto invalid;
|
|
}
|
|
(void)snprintf(z->label, sizeof(z->label), "%s", catalog->display_name);
|
|
++out->zone_count;
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
invalid:
|
|
if (s != NULL) {
|
|
(void)sqlite3_finalize(s);
|
|
}
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
fail:
|
|
if (s != NULL) {
|
|
(void)sqlite3_finalize(s);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus
|
|
load_maxima(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
|
|
static const char SQL[] =
|
|
"SELECT "
|
|
"s.session_id,se.entry_id,e.exercise_id,e.name,COALESCE(se.equipment_id,''),m.max_weight_"
|
|
"kg,s.started_at 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 unixepoch(s.started_at)<=?1 ORDER BY "
|
|
"unixepoch(s.started_at) DESC,s.session_id DESC,se.entry_id DESC LIMIT ?2;";
|
|
sqlite3_stmt *s = NULL;
|
|
int step;
|
|
if (!prepare(d, SQL, &s) || sqlite3_bind_int64(s, 1, now) != SQLITE_OK ||
|
|
sqlite3_bind_int64(s, 2, (sqlite3_int64)(TRAINLOG_WEB_MAX_RECORDS + 1U)) != SQLITE_OK) {
|
|
goto fail;
|
|
}
|
|
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
|
|
TrainlogWebMaxRecord *r;
|
|
if (out->max_record_count == TRAINLOG_WEB_MAX_RECORDS) {
|
|
out->partial = true;
|
|
continue;
|
|
}
|
|
r = &out->max_records[out->max_record_count];
|
|
if (!copy_column(s, 0, r->session_id, sizeof(r->session_id)) ||
|
|
!copy_column(s, 1, r->entry_id, sizeof(r->entry_id)) ||
|
|
!copy_column(s, 2, r->exercise_id, sizeof(r->exercise_id)) ||
|
|
!copy_column(s, 3, r->exercise_name, sizeof(r->exercise_name)) ||
|
|
!copy_column(s, 4, r->equipment_id, sizeof(r->equipment_id)) ||
|
|
!copy_column(s, 6, r->timestamp, sizeof(r->timestamp)) ||
|
|
(sqlite3_column_type(s, 5) != SQLITE_FLOAT &&
|
|
sqlite3_column_type(s, 5) != SQLITE_INTEGER) ||
|
|
!isfinite(sqlite3_column_double(s, 5)) || sqlite3_column_double(s, 5) <= 0.0) {
|
|
out->invalid_data = true;
|
|
continue;
|
|
}
|
|
r->weight_kg = sqlite3_column_double(s, 5);
|
|
++out->max_record_count;
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (s != NULL) {
|
|
(void)sqlite3_finalize(s);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus
|
|
load_zones(TrainlogDatabase *d, int64_t now, TrainlogWebDashboardSnapshot *out) {
|
|
static const char SQL[] =
|
|
"SELECT z.zone_id,COUNT(DISTINCT s.id),COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM "
|
|
"sessions s JOIN session_exercises se ON se.session_row_id=s.id JOIN exercises e ON "
|
|
"e.id=se.exercise_row_id JOIN exercise_body_zones z ON z.exercise_row_id=e.id AND "
|
|
"z.role='primary' LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id WHERE "
|
|
"substr(s.started_at,1,10)>=?1 AND unixepoch(s.started_at)<=?2 AND (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)) GROUP BY z.zone_id ORDER BY "
|
|
"z.zone_id;";
|
|
sqlite3_stmt *s = NULL;
|
|
int step;
|
|
if (!prepare(d, SQL, &s) ||
|
|
sqlite3_bind_text(
|
|
s, 1, out->activity[TRAINLOG_WEB_ACTIVITY_DAYS - 30U].date, -1, SQLITE_TRANSIENT) !=
|
|
SQLITE_OK ||
|
|
sqlite3_bind_int64(s, 2, now) != SQLITE_OK) {
|
|
goto fail;
|
|
}
|
|
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
|
|
TrainlogWebWorkedZone *z;
|
|
const TrainlogBodyZone *catalog;
|
|
if (out->muscle_zone_count == TRAINLOG_WEB_ZONE_CAPACITY) {
|
|
out->partial = true;
|
|
continue;
|
|
}
|
|
z = &out->muscle_zones[out->muscle_zone_count];
|
|
if (!copy_column(s, 0, z->zone_id, sizeof(z->zone_id)) ||
|
|
!read_size(s, 1, &z->session_count) || !read_size(s, 2, &z->occurrence_count) ||
|
|
!read_size(s, 3, &z->set_count)) {
|
|
goto fail;
|
|
}
|
|
catalog = trainlog_body_zone_catalog_lookup(z->zone_id);
|
|
if (catalog == NULL || strlen(catalog->display_name) > TRAINLOG_NAME_MAX) {
|
|
out->invalid_data = true;
|
|
continue;
|
|
}
|
|
(void)snprintf(z->label, sizeof(z->label), "%s", catalog->display_name);
|
|
++out->muscle_zone_count;
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (s != NULL) {
|
|
(void)sqlite3_finalize(s);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
static TrainlogStatus scan_quality(TrainlogDatabase *d, TrainlogWebDashboardSnapshot *out) {
|
|
static const char SQL[] =
|
|
"SELECT s.started_at,COALESCE(s.ended_at,'') FROM sessions s WHERE EXISTS(SELECT 1 FROM "
|
|
"session_exercises x JOIN performed_sets p ON p.session_exercise_row_id=x.id WHERE "
|
|
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN "
|
|
"continuous_activity c ON c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR "
|
|
"EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
|
|
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) ORDER BY s.id LIMIT 4097;";
|
|
sqlite3_stmt *s = NULL;
|
|
int step;
|
|
size_t count = 0U;
|
|
if (!prepare(d, SQL, &s)) {
|
|
goto fail;
|
|
}
|
|
while ((step = sqlite3_step(s)) == SQLITE_ROW) {
|
|
char started[TRAINLOG_TIMESTAMP_MAX + 1U], ended[TRAINLOG_TIMESTAMP_MAX + 1U];
|
|
TrainlogTimestampKey start_key, end_key;
|
|
++count;
|
|
if (count > 4096U) {
|
|
out->partial = true;
|
|
continue;
|
|
}
|
|
if (!copy_column(s, 0, started, sizeof(started)) ||
|
|
!copy_column(s, 1, ended, sizeof(ended)) ||
|
|
!trainlog_timestamp_parse(started, strlen(started), &start_key) ||
|
|
(ended[0] != '\0' && (!trainlog_timestamp_parse(ended, strlen(ended), &end_key) ||
|
|
trainlog_timestamp_compare(&end_key, &start_key) < 0))) {
|
|
out->invalid_data = true;
|
|
}
|
|
}
|
|
if (step != SQLITE_DONE || sqlite3_finalize(s) != SQLITE_OK) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
return TRAINLOG_STATUS_OK;
|
|
fail:
|
|
if (s != NULL) {
|
|
(void)sqlite3_finalize(s);
|
|
}
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
|
|
TrainlogStatus trainlog_web_dashboard_load(TrainlogDatabase *d,
|
|
const TrainlogWebDashboardQuery *q,
|
|
TrainlogWebDashboardSnapshot *out) {
|
|
TrainlogStatus status, end_status;
|
|
TrainlogDashboardQuery progression_query;
|
|
if (d == NULL || q == NULL || out == NULL) {
|
|
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
|
}
|
|
(void)memset(out, 0, sizeof(*out));
|
|
out->next_session_reason = "no_persisted_executable_plan";
|
|
out->cardio_reason = "no_cardio_data_source";
|
|
if (!format_generated(q->reference_unix_second, out->generated_at) ||
|
|
!load_user(out->user_display_name)) {
|
|
return TRAINLOG_STATUS_DATABASE_ERROR;
|
|
}
|
|
status = trainlog_database_read_snapshot_begin(d);
|
|
if (status != TRAINLOG_STATUS_OK) {
|
|
return status;
|
|
}
|
|
status = load_activity(d, q->reference_unix_second, out);
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = scan_quality(d, out);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
progression_query.period = TRAINLOG_DASHBOARD_90_DAYS;
|
|
progression_query.reference_unix_second = q->reference_unix_second;
|
|
status = trainlog_dashboard_load(d, &progression_query, &out->progression);
|
|
out->partial = out->partial || out->progression.partial;
|
|
out->invalid_data = out->invalid_data || out->progression.invalid_data;
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = load_last(d, q->reference_unix_second, &out->last_session);
|
|
if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
|
|
out->invalid_data = true;
|
|
status = TRAINLOG_STATUS_OK;
|
|
}
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = load_maxima(d, q->reference_unix_second, out);
|
|
}
|
|
if (status == TRAINLOG_STATUS_OK) {
|
|
status = load_zones(d, q->reference_unix_second, out);
|
|
}
|
|
end_status = trainlog_database_read_snapshot_end(d, status == TRAINLOG_STATUS_OK);
|
|
if (end_status != TRAINLOG_STATUS_OK) {
|
|
status = end_status;
|
|
}
|
|
return status;
|
|
}
|