#include "trainlog/web_analysis.h" #include #include #include #include #include #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; }