trainlog/tui/src/web_analysis.c

791 lines
40 KiB
C

#include "trainlog/web_analysis.h"
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.h"
#include "trainlog/body_zone_catalog.h"
#define SECONDS_PER_DAY INT64_C(86400)
typedef struct MeasurementDefinition {
const char *id;
const char *column;
const char *unit;
} MeasurementDefinition;
static const MeasurementDefinition MEASUREMENTS[] = {
{"weight", "body_weight_kg", "kg"},
{"neck", "neck_cm", "cm"},
{"shoulders", "shoulders_cm", "cm"},
{"chest", "chest_cm", "cm"},
{"waist", "waist_cm", "cm"},
{"hips", "hips_cm", "cm"},
{"left_arm", "left_arm_cm", "cm"},
{"right_arm", "right_arm_cm", "cm"},
{"left_forearm", "left_forearm_cm", "cm"},
{"right_forearm", "right_forearm_cm", "cm"},
{"left_thigh", "left_thigh_cm", "cm"},
{"right_thigh", "right_thigh_cm", "cm"},
{"left_calf", "left_calf_cm", "cm"},
{"right_calf", "right_calf_cm", "cm"},
};
static const MeasurementDefinition *measurement_definition(const char *id) {
size_t index;
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
if (strcmp(id, MEASUREMENTS[index].id) == 0) {
return &MEASUREMENTS[index];
}
}
return NULL;
}
static bool prepare(TrainlogDatabase *database, const char *sql, sqlite3_stmt **statement) {
return sqlite3_prepare_v2(database->connection, sql, -1, statement, NULL) == SQLITE_OK;
}
static bool bind_interval(sqlite3_stmt *statement, const TrainlogWebAnalysisQuery *query) {
if (query->period == TRAINLOG_WEB_ANALYSIS_ALL) {
if (sqlite3_bind_null(statement, 1) != SQLITE_OK) {
return false;
}
} else {
int64_t days = (int64_t)query->period;
int64_t start = query->reference_unix_second - ((days - 1) * SECONDS_PER_DAY);
if (sqlite3_bind_int64(statement, 1, start) != SQLITE_OK) {
return false;
}
}
return sqlite3_bind_int64(statement, 2, query->reference_unix_second) == SQLITE_OK;
}
static bool add_nullable_real(yyjson_mut_doc *document,
yyjson_mut_val *object,
const char *key,
sqlite3_stmt *statement,
int column) {
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
return yyjson_mut_obj_add_null(document, object, key);
}
return yyjson_mut_obj_add_real(document, object, key, sqlite3_column_double(statement, column));
}
static bool add_overview(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
static const char SQL[] =
"WITH eligible AS (SELECT s.id,s.started_at,s.ended_at FROM sessions s WHERE "
"(?1 IS NULL OR unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?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))) SELECT COUNT(*),"
"(SELECT COUNT(*) FROM performed_sets ps JOIN session_exercises se ON "
"se.id=ps.session_exercise_row_id JOIN eligible e ON e.id=se.session_row_id),"
"CASE WHEN COUNT(*)>0 AND COUNT(*)=SUM(CASE WHEN ended_at IS NOT NULL AND "
"unixepoch(ended_at)>=unixepoch(started_at) THEN 1 ELSE 0 END) THEN "
"SUM(unixepoch(ended_at)-unixepoch(started_at)) ELSE NULL END,"
"(SELECT COUNT(DISTINCT se.exercise_row_id) FROM session_exercises se JOIN eligible e "
"ON e.id=se.session_row_id 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)) FROM eligible";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *overview = yyjson_mut_obj(document);
bool ok;
if (overview == NULL || !prepare(database, SQL, &statement) ||
!bind_interval(statement, query) || sqlite3_step(statement) != SQLITE_ROW) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
ok = yyjson_mut_obj_add_sint(
document, overview, "sessions", sqlite3_column_int64(statement, 0)) &&
yyjson_mut_obj_add_sint(document, overview, "sets", sqlite3_column_int64(statement, 1)) &&
(sqlite3_column_type(statement, 2) == SQLITE_NULL
? yyjson_mut_obj_add_null(document, overview, "duration_seconds")
: yyjson_mut_obj_add_sint(
document, overview, "duration_seconds", sqlite3_column_int64(statement, 2))) &&
yyjson_mut_obj_add_sint(
document, overview, "distinct_exercises", sqlite3_column_int64(statement, 3)) &&
yyjson_mut_obj_add_val(document, root, "overview", overview) &&
sqlite3_step(statement) == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK;
return ok;
}
static bool add_activity(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root,
bool *partial) {
static const char SQL[] =
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(DISTINCT ps.id),"
"COUNT(DISTINCT se.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 (?1 IS NULL OR "
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL 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 DESC LIMIT ?3";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *activity = yyjson_mut_arr(document);
size_t count = 0U;
int step;
if (activity == NULL || !prepare(database, SQL, &statement) ||
!bind_interval(statement, query) ||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U)) !=
SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *day;
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
*partial = true;
continue;
}
day = yyjson_mut_obj(document);
if (day == NULL ||
!yyjson_mut_obj_add_strcpy(
document, day, "date", (const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_sint(
document, day, "sessions", sqlite3_column_int64(statement, 1)) ||
!yyjson_mut_obj_add_sint(document, day, "sets", sqlite3_column_int64(statement, 2)) ||
!yyjson_mut_obj_add_sint(
document, day, "exercises", sqlite3_column_int64(statement, 3)) ||
!yyjson_mut_arr_add_val(activity, day)) {
(void)sqlite3_finalize(statement);
return false;
}
++count;
}
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
yyjson_mut_obj_add_val(document, root, "activity", activity);
}
/* WHY: the analysis entry point must progress from primary BODY ZONE to
* exercise before showing an individual curve. CONTRACT: measured_seconds is
* derived only from imported occurrence start/end timestamps. INVARIANT: no
* duration is inferred from sets, repetitions, rest targets or session time. */
static bool add_exercise_groups(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
static const char SQL[] =
"SELECT z.zone_id,e.exercise_id,e.name,"
"COUNT(DISTINCT CASE WHEN s.id IS NOT NULL THEN se.id END),"
"COUNT(DISTINCT s.id),COUNT(DISTINCT ps.id),"
"COALESCE((SELECT SUM(unixepoch(tt.ended_at)-unixepoch(tt.started_at)) FROM "
"session_exercise_timeline tt JOIN sessions ts ON ts.session_id=tt.session_id WHERE "
"tt.exercise_id=e.exercise_id AND tt.ended_at>=tt.started_at AND (?1 IS NULL OR "
"unixepoch(ts.started_at)>=?1) AND unixepoch(ts.started_at)<=?2),0),"
"(SELECT COUNT(*) FROM session_exercise_timeline tt JOIN sessions ts ON "
"ts.session_id=tt.session_id WHERE tt.exercise_id=e.exercise_id AND "
"tt.ended_at>=tt.started_at AND (?1 IS NULL OR unixepoch(ts.started_at)>=?1) AND "
"unixepoch(ts.started_at)<=?2) "
"FROM exercise_body_zones z JOIN exercises e ON e.id=z.exercise_row_id AND "
"z.role='primary' LEFT JOIN session_exercises se ON se.exercise_row_id=e.id "
"LEFT JOIN sessions s ON s.id=se.session_row_id AND (?1 IS NULL OR "
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 "
"LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id AND s.id IS NOT NULL "
"GROUP BY z.zone_id,e.exercise_id,e.name "
"HAVING COUNT(DISTINCT s.id)>0 ORDER BY z.zone_id,e.name COLLATE NOCASE,e.exercise_id";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *groups = yyjson_mut_arr(document);
yyjson_mut_val *group = NULL;
yyjson_mut_val *exercises = NULL;
char current_zone[64] = "";
int step;
if (groups == NULL || !prepare(database, SQL, &statement) || !bind_interval(statement, query)) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
const char *zone_id = (const char *)sqlite3_column_text(statement, 0);
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(zone_id);
yyjson_mut_val *exercise;
if (zone == NULL) {
(void)sqlite3_finalize(statement);
return false;
}
if (strcmp(current_zone, zone_id) != 0) {
if (group != NULL && !yyjson_mut_obj_add_val(document, group, "exercises", exercises)) {
(void)sqlite3_finalize(statement);
return false;
}
group = yyjson_mut_obj(document);
exercises = yyjson_mut_arr(document);
if (group == NULL || exercises == NULL ||
!yyjson_mut_obj_add_strcpy(document, group, "zone_id", zone_id) ||
!yyjson_mut_obj_add_strcpy(document, group, "label", zone->display_name) ||
!yyjson_mut_arr_add_val(groups, group)) {
(void)sqlite3_finalize(statement);
return false;
}
(void)snprintf(current_zone, sizeof(current_zone), "%s", zone_id);
}
exercise = yyjson_mut_obj(document);
if (exercise == NULL ||
!yyjson_mut_obj_add_strcpy(document,
exercise,
"exercise_id",
(const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_obj_add_strcpy(
document, exercise, "name", (const char *)sqlite3_column_text(statement, 2)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "occurrences", sqlite3_column_int64(statement, 3)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "sessions", sqlite3_column_int64(statement, 4)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "sets", sqlite3_column_int64(statement, 5)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "measured_seconds", sqlite3_column_int64(statement, 6)) ||
!yyjson_mut_obj_add_sint(
document, exercise, "measured_occurrences", sqlite3_column_int64(statement, 7)) ||
!yyjson_mut_arr_add_val(exercises, exercise)) {
(void)sqlite3_finalize(statement);
return false;
}
}
if (group != NULL && !yyjson_mut_obj_add_val(document, group, "exercises", exercises)) {
(void)sqlite3_finalize(statement);
return false;
}
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
yyjson_mut_obj_add_val(document, root, "exercise_groups", groups);
}
static bool add_exercises(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root,
char selected_id[64],
bool *partial) {
static const char LIST_SQL[] =
"SELECT e.exercise_id,e.name,e.tracking_mode,e.recording_mode FROM exercises e ORDER BY "
"e.name COLLATE NOCASE,e.exercise_id LIMIT ?1";
static const char RECENT_SQL[] =
"SELECT e.exercise_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 WHERE (?1 IS NULL OR "
"unixepoch(s.started_at)>=?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)) ORDER BY "
"unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *items = yyjson_mut_arr(document);
size_t count = 0U;
int step;
if (query->exercise_id != NULL) {
(void)snprintf(selected_id, 64U, "%s", query->exercise_id);
} else if (prepare(database, RECENT_SQL, &statement) && bind_interval(statement, query)) {
if (sqlite3_step(statement) == SQLITE_ROW) {
(void)snprintf(selected_id, 64U, "%s", (const char *)sqlite3_column_text(statement, 0));
}
(void)sqlite3_finalize(statement);
statement = NULL;
} else {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
if (items == NULL || !prepare(database, LIST_SQL, &statement) ||
sqlite3_bind_int64(statement, 1, TRAINLOG_WEB_ANALYSIS_EXERCISES_MAX + 1U) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *item;
if (count == TRAINLOG_WEB_ANALYSIS_EXERCISES_MAX) {
*partial = true;
continue;
}
item = yyjson_mut_obj(document);
if (item == NULL ||
!yyjson_mut_obj_add_strcpy(
document, item, "exercise_id", (const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_strcpy(
document, item, "name", (const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_obj_add_strcpy(
document, item, "tracking_mode", (const char *)sqlite3_column_text(statement, 2)) ||
!yyjson_mut_obj_add_strcpy(document,
item,
"recording_mode",
(const char *)sqlite3_column_text(statement, 3)) ||
!yyjson_mut_arr_add_val(items, item)) {
(void)sqlite3_finalize(statement);
return false;
}
++count;
}
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
yyjson_mut_obj_add_val(document, root, "exercises", items);
}
static bool add_exercise_detail(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
const char *exercise_id,
yyjson_mut_doc *document,
yyjson_mut_val *root,
bool *partial) {
static const char PROFILE_SQL[] =
"SELECT name,tracking_mode,recording_mode FROM exercises WHERE exercise_id=?1";
static const char POINT_SQL[] =
"SELECT s.session_id,s.started_at,se.load_mode,COUNT(ps.id),SUM(ps.reps),"
"SUM(ps.duration_seconds),CASE WHEN se.load_mode='external' THEN AVG(ps.weight_kg) ELSE "
"NULL END,CASE WHEN se.load_mode='external' AND COUNT(ps.id)>0 AND "
"COUNT(ps.id)=COUNT(ps.weight_kg) THEN SUM(ps.reps*ps.weight_kg) ELSE NULL END,"
"SUM(c.duration_seconds),SUM(c.distance_km),AVG(c.speed_kmh),m.max_weight_kg 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 ps ON ps.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=?3 AND (?1 IS NULL OR "
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL OR "
"c.id IS NOT NULL OR m.session_exercise_row_id IS NOT NULL) GROUP BY se.id ORDER BY "
"unixepoch(s.started_at) DESC,s.session_id DESC LIMIT ?4";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *detail = yyjson_mut_obj(document);
yyjson_mut_val *points = yyjson_mut_arr(document);
size_t count = 0U;
int step;
if (detail == NULL || points == NULL) {
return false;
}
if (exercise_id[0] == '\0') {
return yyjson_mut_obj_add_null(document, root, "exercise");
}
if (!prepare(database, PROFILE_SQL, &statement) ||
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
step = sqlite3_step(statement);
if (step == SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return yyjson_mut_obj_add_null(document, root, "exercise");
}
if (step != SQLITE_ROW ||
!yyjson_mut_obj_add_strcpy(document, detail, "exercise_id", exercise_id) ||
!yyjson_mut_obj_add_strcpy(
document, detail, "name", (const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_strcpy(
document, detail, "tracking_mode", (const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_obj_add_strcpy(
document, detail, "recording_mode", (const char *)sqlite3_column_text(statement, 2)) ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return false;
}
statement = NULL;
if (!prepare(database, POINT_SQL, &statement) || !bind_interval(statement, query) ||
sqlite3_bind_text(statement, 3, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_int64(statement, 4, TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *point;
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
*partial = true;
continue;
}
point = yyjson_mut_obj(document);
if (point == NULL ||
!yyjson_mut_obj_add_strcpy(
document, point, "session_id", (const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_strcpy(
document, point, "timestamp", (const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_obj_add_strcpy(
document, point, "load_mode", (const char *)sqlite3_column_text(statement, 2)) ||
!yyjson_mut_obj_add_sint(document, point, "sets", sqlite3_column_int64(statement, 3)) ||
!add_nullable_real(document, point, "reps", statement, 4) ||
!add_nullable_real(document, point, "set_duration_seconds", statement, 5) ||
!add_nullable_real(document, point, "external_load_kg", statement, 6) ||
!add_nullable_real(document, point, "external_volume_kg", statement, 7) ||
!add_nullable_real(document, point, "continuous_duration_seconds", statement, 8) ||
!add_nullable_real(document, point, "distance_km", statement, 9) ||
!add_nullable_real(document, point, "speed_kmh", statement, 10) ||
!add_nullable_real(document, point, "explicit_max_kg", statement, 11) ||
!yyjson_mut_arr_add_val(points, point)) {
(void)sqlite3_finalize(statement);
return false;
}
++count;
}
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
yyjson_mut_obj_add_val(document, detail, "points", points) &&
yyjson_mut_obj_add_val(document, root, "exercise", detail);
}
static bool add_zones(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
static const char 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 exercise_body_zones z ON "
"z.exercise_row_id=se.exercise_row_id AND z.role='primary' LEFT JOIN performed_sets ps ON "
"ps.session_exercise_row_id=se.id WHERE (?1 IS NULL OR unixepoch(s.started_at)>=?1) AND "
"unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL 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 "
"COUNT(DISTINCT se.id) DESC,z.zone_id";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *zones = yyjson_mut_arr(document);
int step;
if (zones == NULL || !prepare(database, SQL, &statement) || !bind_interval(statement, query)) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
const char *id = (const char *)sqlite3_column_text(statement, 0);
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(id);
yyjson_mut_val *item = yyjson_mut_obj(document);
if (zone == NULL || item == NULL ||
!yyjson_mut_obj_add_strcpy(document, item, "zone_id", id) ||
!yyjson_mut_obj_add_strcpy(document, item, "label", zone->display_name) ||
!yyjson_mut_obj_add_sint(
document, item, "exposures", sqlite3_column_int64(statement, 1)) ||
!yyjson_mut_obj_add_sint(
document, item, "associated_sets", sqlite3_column_int64(statement, 2)) ||
!yyjson_mut_arr_add_val(zones, item)) {
(void)sqlite3_finalize(statement);
return false;
}
}
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
yyjson_mut_obj_add_val(document, root, "body_zones", zones);
}
static bool add_measurements(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
yyjson_mut_doc *document,
yyjson_mut_val *root,
bool *partial) {
yyjson_mut_val *measurements = yyjson_mut_obj(document);
yyjson_mut_val *summaries = yyjson_mut_arr(document);
yyjson_mut_val *series = yyjson_mut_arr(document);
yyjson_mut_val *points = yyjson_mut_arr(document);
const MeasurementDefinition *selected = measurement_definition(query->measurement_metric);
size_t index;
if (measurements == NULL || summaries == NULL || series == NULL || points == NULL ||
selected == NULL) {
return false;
}
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
char sql[512];
sqlite3_stmt *statement = NULL;
yyjson_mut_val *summary = yyjson_mut_obj(document);
int written = snprintf(
sql,
sizeof(sql),
"WITH filtered AS (SELECT observed_at,observation_id,%s AS value FROM "
"body_observations WHERE %s IS NOT NULL AND (?1 IS NULL OR "
"unixepoch(observed_at)>=?1) AND unixepoch(observed_at)<=?2) SELECT COUNT(*),"
"(SELECT value FROM filtered ORDER BY unixepoch(observed_at),observation_id LIMIT 1),"
"(SELECT value FROM filtered ORDER BY unixepoch(observed_at) DESC,observation_id DESC "
"LIMIT 1) FROM filtered",
MEASUREMENTS[index].column,
MEASUREMENTS[index].column);
if (written < 0 || (size_t)written >= sizeof(sql) || summary == NULL ||
!prepare(database, sql, &statement) || !bind_interval(statement, query) ||
sqlite3_step(statement) != SQLITE_ROW ||
!yyjson_mut_obj_add_strcpy(document, summary, "metric", MEASUREMENTS[index].id) ||
!yyjson_mut_obj_add_strcpy(document, summary, "unit", MEASUREMENTS[index].unit) ||
!yyjson_mut_obj_add_sint(
document, summary, "count", sqlite3_column_int64(statement, 0)) ||
!add_nullable_real(document, summary, "first", statement, 1) ||
!add_nullable_real(document, summary, "last", statement, 2) ||
!(sqlite3_column_int64(statement, 0) < 2
? yyjson_mut_obj_add_null(document, summary, "delta")
: yyjson_mut_obj_add_real(document,
summary,
"delta",
sqlite3_column_double(statement, 2) -
sqlite3_column_double(statement, 1))) ||
!yyjson_mut_arr_add_val(summaries, summary) || sqlite3_step(statement) != SQLITE_DONE ||
sqlite3_finalize(statement) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
}
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
char sql[512];
sqlite3_stmt *statement = NULL;
yyjson_mut_val *metric_points = yyjson_mut_arr(document);
yyjson_mut_val *metric_series;
size_t count = 0U;
int step;
int written = snprintf(
sql,
sizeof(sql),
"SELECT observed_at,%s FROM body_observations WHERE %s IS NOT NULL "
"AND (?1 IS NULL OR unixepoch(observed_at)>=?1) AND unixepoch(observed_at)<=?2 "
"ORDER BY unixepoch(observed_at),observation_id LIMIT ?3",
MEASUREMENTS[index].column,
MEASUREMENTS[index].column);
if (metric_points == NULL || written < 0 || (size_t)written >= sizeof(sql) ||
!prepare(database, sql, &statement) || !bind_interval(statement, query) ||
sqlite3_bind_int64(statement, 3, TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *point;
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
*partial = true;
continue;
}
point = yyjson_mut_obj(document);
if (point == NULL ||
!yyjson_mut_obj_add_strcpy(document,
point,
"timestamp",
(const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_real(
document, point, "value", sqlite3_column_double(statement, 1)) ||
!yyjson_mut_arr_add_val(metric_points, point)) {
(void)sqlite3_finalize(statement);
return false;
}
++count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return false;
}
if (count < 2U) {
continue;
}
metric_series = yyjson_mut_obj(document);
if (metric_series == NULL ||
!yyjson_mut_obj_add_strcpy(document, metric_series, "metric", MEASUREMENTS[index].id) ||
!yyjson_mut_obj_add_strcpy(document, metric_series, "unit", MEASUREMENTS[index].unit) ||
!yyjson_mut_obj_add_val(document, metric_series, "points", metric_points) ||
!yyjson_mut_arr_add_val(series, metric_series)) {
return false;
}
}
{
char sql[512];
sqlite3_stmt *statement = NULL;
size_t count = 0U;
int step;
int written = snprintf(sql,
sizeof(sql),
"SELECT observed_at,%s FROM body_observations WHERE %s IS NOT NULL "
"AND (?1 IS NULL OR unixepoch(observed_at)>=?1) AND "
"unixepoch(observed_at)<=?2 ORDER BY unixepoch(observed_at) DESC,"
"observation_id DESC LIMIT ?3",
selected->column,
selected->column);
if (written < 0 || (size_t)written >= sizeof(sql) || !prepare(database, sql, &statement) ||
!bind_interval(statement, query) ||
sqlite3_bind_int64(statement, 3, TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U) != SQLITE_OK) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
yyjson_mut_val *point;
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
*partial = true;
continue;
}
point = yyjson_mut_obj(document);
if (point == NULL ||
!yyjson_mut_obj_add_strcpy(document,
point,
"timestamp",
(const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_real(
document, point, "value", sqlite3_column_double(statement, 1)) ||
!yyjson_mut_arr_add_val(points, point)) {
(void)sqlite3_finalize(statement);
return false;
}
++count;
}
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return false;
}
}
return yyjson_mut_obj_add_val(document, measurements, "summaries", summaries) &&
yyjson_mut_obj_add_val(document, measurements, "series", series) &&
yyjson_mut_obj_add_strcpy(document, measurements, "selected_metric", selected->id) &&
yyjson_mut_obj_add_strcpy(document, measurements, "unit", selected->unit) &&
yyjson_mut_obj_add_val(document, measurements, "points", points) &&
yyjson_mut_obj_add_val(document, root, "measurements", measurements);
}
static bool
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
static const char SQL[] =
/* WHY: imported dates are historical after Program rescheduling.
* CONTRACT: select one pending, non-ceded session with its current date.
* INVARIANT: a present planning row with NULL current_for stays undated. */
"WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position,"
"CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END "
"AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON "
"pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 "
"ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')"
"!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted')),"
"ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY "
"CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position,"
"program_session_id) AS rank FROM eligible) "
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
"next.title,next.program_session_id,next.current_for "
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
"program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN "
"ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE "
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
"DESC,p.program_id "
"LIMIT 1";
sqlite3_stmt *statement = NULL;
yyjson_mut_val *program = yyjson_mut_obj(document);
int step;
if (program == NULL || !prepare(database, SQL, &statement)) {
if (statement != NULL) {
(void)sqlite3_finalize(statement);
}
return false;
}
step = sqlite3_step(statement);
if (step == SQLITE_DONE) {
(void)sqlite3_finalize(statement);
return yyjson_mut_obj_add_null(document, root, "active_program");
}
if (step != SQLITE_ROW ||
!yyjson_mut_obj_add_strcpy(
document, program, "program_id", (const char *)sqlite3_column_text(statement, 0)) ||
!yyjson_mut_obj_add_strcpy(
document, program, "title", (const char *)sqlite3_column_text(statement, 1)) ||
!yyjson_mut_obj_add_sint(
document, program, "total_sessions", sqlite3_column_int64(statement, 2)) ||
!yyjson_mut_obj_add_sint(
document, program, "completed_sessions", sqlite3_column_int64(statement, 3)) ||
!(sqlite3_column_type(statement, 4) == SQLITE_NULL
? yyjson_mut_obj_add_null(document, program, "next_session_title")
: yyjson_mut_obj_add_strcpy(document,
program,
"next_session_title",
(const char *)sqlite3_column_text(statement, 4))) ||
!(sqlite3_column_type(statement, 5) == SQLITE_NULL
? yyjson_mut_obj_add_null(document, program, "next_session_id")
: yyjson_mut_obj_add_strcpy(document,
program,
"next_session_id",
(const char *)sqlite3_column_text(statement, 5))) ||
!(sqlite3_column_type(statement, 6) == SQLITE_NULL
? yyjson_mut_obj_add_null(document, program, "next_session_planned_for")
: yyjson_mut_obj_add_strcpy(document,
program,
"next_session_planned_for",
(const char *)sqlite3_column_text(statement, 6))) ||
!yyjson_mut_obj_add_val(document, root, "active_program", program) ||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return false;
}
return true;
}
TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database,
const TrainlogWebAnalysisQuery *query,
char *output,
size_t capacity,
size_t *output_size) {
yyjson_mut_doc *document;
yyjson_mut_val *root;
yyjson_mut_val *meta;
char selected_id[64] = "";
bool partial = false;
char *json;
size_t json_size;
yyjson_write_err error;
if (database == NULL || query == NULL || output == NULL || capacity == 0U ||
output_size == NULL || query->reference_unix_second < 0 ||
(query->period != TRAINLOG_WEB_ANALYSIS_7_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_14_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_21_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_30_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_90_DAYS &&
query->period != TRAINLOG_WEB_ANALYSIS_ALL) ||
query->measurement_metric == NULL ||
measurement_definition(query->measurement_metric) == NULL ||
(query->exercise_id != NULL && strlen(query->exercise_id) >= sizeof(selected_id))) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_size = 0U;
output[0] = '\0';
document = yyjson_mut_doc_new(NULL);
root = document == NULL ? NULL : yyjson_mut_obj(document);
meta = document == NULL ? NULL : yyjson_mut_obj(document);
if (document == NULL || root == NULL || meta == NULL) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(document, root);
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
!yyjson_mut_obj_add_strcpy(document,
root,
"period",
query->period == TRAINLOG_WEB_ANALYSIS_ALL ? "all"
: query->period == TRAINLOG_WEB_ANALYSIS_7_DAYS ? "7d"
: query->period == TRAINLOG_WEB_ANALYSIS_14_DAYS ? "14d"
: query->period == TRAINLOG_WEB_ANALYSIS_21_DAYS ? "21d"
: query->period == TRAINLOG_WEB_ANALYSIS_30_DAYS ? "30d"
: "90d") ||
!add_overview(database, query, document, root) ||
!add_activity(database, query, document, root, &partial) ||
!add_exercise_groups(database, query, document, root) ||
!add_exercises(database, query, document, root, selected_id, &partial) ||
!add_exercise_detail(database, query, selected_id, document, root, &partial) ||
!add_zones(database, query, document, root) ||
!add_measurements(database, query, document, root, &partial) ||
!add_program(database, document, root) ||
!yyjson_mut_obj_add_bool(document, meta, "partial", partial) ||
!yyjson_mut_obj_add_sint(
document, meta, "reference_unix_second", query->reference_unix_second) ||
!yyjson_mut_obj_add_val(document, root, "meta", meta)) {
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, &json_size, &error);
yyjson_mut_doc_free(document);
if (json == NULL || json_size + 2U > capacity) {
free(json);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
(void)memcpy(output, json, json_size);
output[json_size] = '\n';
output[json_size + 1U] = '\0';
*output_size = json_size + 1U;
free(json);
return TRAINLOG_STATUS_OK;
}