diff --git a/tui/include/trainlog/web_analysis.h b/tui/include/trainlog/web_analysis.h new file mode 100644 index 0000000..f81cd0e --- /dev/null +++ b/tui/include/trainlog/web_analysis.h @@ -0,0 +1,41 @@ +/* Trainlog Web Analyse V1 bounded Core read model. */ +#ifndef TRAINLOG_WEB_ANALYSIS_H +#define TRAINLOG_WEB_ANALYSIS_H + +#include +#include + +#include "trainlog/database.h" +#include "trainlog/status.h" + +#define TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY (256U * 1024U) +#define TRAINLOG_WEB_ANALYSIS_EXERCISES_MAX 128U +#define TRAINLOG_WEB_ANALYSIS_POINTS_MAX 90U + +typedef enum TrainlogWebAnalysisPeriod { + TRAINLOG_WEB_ANALYSIS_7_DAYS = 7, + TRAINLOG_WEB_ANALYSIS_30_DAYS = 30, + TRAINLOG_WEB_ANALYSIS_90_DAYS = 90, + TRAINLOG_WEB_ANALYSIS_ALL = 0 +} TrainlogWebAnalysisPeriod; + +typedef struct TrainlogWebAnalysisQuery { + TrainlogWebAnalysisPeriod period; + int64_t reference_unix_second; + const char *exercise_id; + const char *measurement_metric; +} TrainlogWebAnalysisQuery; + +/* WHY: Dashboard and Analyse must not independently reinterpret history. + * CONTRACT: the Core filters by the inclusive UTC interval and returns one + * bounded, typed JSON projection; absent observations are JSON null, never + * fabricated zeroes. exercise_id and metric are borrowed for this call. + * INVARIANT: MAX values come only from max_results and BODY ZONE counts are + * recorded primary-zone exposures, not physiological percentages. */ +TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database, + const TrainlogWebAnalysisQuery *query, + char *output, + size_t capacity, + size_t *output_size); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 706b5e3..80efe89 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -47,6 +47,9 @@ if web_assets_enabled '../web/src/vite-env.d.ts', '../web/src/api/health.ts', '../web/src/api/dashboard.ts', + '../web/src/api/analysis.ts', + '../web/src/api/analysis.test.ts', + '../web/src/analysis/messages.ts', '../web/src/api/dashboardLayout.ts', '../web/src/api/preparedItems.ts', '../web/src/api/sync.ts', @@ -73,6 +76,8 @@ if web_assets_enabled '../web/src/presentation/DatePreferences.tsx', '../web/src/presentation/dateFormat.ts', '../web/src/routes/DashboardPage.tsx', + '../web/src/routes/AnalysisPage.tsx', + '../web/src/routes/AnalysisPage.test.tsx', '../web/src/routes/PlaceholderPage.tsx', '../web/src/routes/SessionsPage.tsx', '../web/src/api/sessions.ts', @@ -208,6 +213,7 @@ trainlog_core_sources = files( 'src/json_writer.c', 'src/web_dashboard.c', 'src/web_dashboard_json.c', + 'src/web_analysis.c', 'src/web_prepared_items.c', 'src/web_prepared_items_json.c', 'src/web_sessions.c', @@ -360,6 +366,15 @@ test_web_dashboard = executable( ) test('web_dashboard', test_web_dashboard) +test_web_analysis = executable( + 'test_web_analysis', + 'tests/test_web_analysis.c', + dependencies: trainlog_core_dep, + include_directories: trainlog_private_include, + c_args: strict_c_args, +) +test('web_analysis', test_web_analysis) + test_web_prepared_items = executable( 'test_web_prepared_items', 'tests/test_web_prepared_items.c', diff --git a/tui/src/web_analysis.c b/tui/src/web_analysis.c new file mode 100644 index 0000000..c53a7cc --- /dev/null +++ b/tui/src/web_analysis.c @@ -0,0 +1,599 @@ +#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 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_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) 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_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); +} + +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 *points = yyjson_mut_arr(document); + const MeasurementDefinition *selected = measurement_definition(query->measurement_metric); + size_t index; + if (measurements == NULL || summaries == 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; + } + } + { + 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_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[] = + "SELECT p.program_id,p.title,COUNT(ps.program_session_id)," + "SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END)," + "(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON " + "pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND " + "COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY " + "CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) " + "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 WHERE " + "p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at " + "DESC " + "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))) || + !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_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_30_DAYS ? "30d" + : "90d") || + !add_overview(database, query, document, root) || + !add_activity(database, query, document, root, &partial) || + !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; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index d9218fa..63f76a8 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -21,6 +21,7 @@ #include #include "trainlog/web_dashboard.h" +#include "trainlog/web_analysis.h" #include "trainlog/web_exercises.h" #include "trainlog/web_prepared_items.h" #include "trainlog/web_programs.h" @@ -64,6 +65,91 @@ static bool valid_sync_id(const char *value) { uuid_parse(value + 3U, (unsigned char[16]){0}) == 0; } +static bool valid_exercise_id(const char *value) { + return value != NULL && strlen(value) == 39U && strncmp(value, "ex_", 3U) == 0 && + uuid_parse(value + 3U, (unsigned char[16]){0}) == 0; +} + +typedef struct TrainlogAnalysisArguments { + bool valid; + unsigned int period_count; + unsigned int exercise_count; + unsigned int metric_count; + unsigned int page_count; +} TrainlogAnalysisArguments; + +static enum MHD_Result validate_analysis_argument(void *context, + enum MHD_ValueKind kind, + const char *key, + const char *value) { + TrainlogAnalysisArguments *arguments = context; + unsigned int *count = NULL; + (void)kind; + (void)value; + if (strcmp(key, "period") == 0) { + count = &arguments->period_count; + } else if (strcmp(key, "exercise_id") == 0) { + count = &arguments->exercise_count; + } else if (strcmp(key, "metric") == 0) { + count = &arguments->metric_count; + } else if (strcmp(key, "page") == 0) { + count = &arguments->page_count; + } else { + arguments->valid = false; + return MHD_NO; + } + ++*count; + if (*count > 1U) { + arguments->valid = false; + return MHD_NO; + } + return MHD_YES; +} + +static bool parse_analysis_period(const char *value, TrainlogWebAnalysisPeriod *period) { + if (value == NULL || strcmp(value, "30d") == 0) { + *period = TRAINLOG_WEB_ANALYSIS_30_DAYS; + return true; + } + if (strcmp(value, "7d") == 0) { + *period = TRAINLOG_WEB_ANALYSIS_7_DAYS; + return true; + } + if (strcmp(value, "90d") == 0) { + *period = TRAINLOG_WEB_ANALYSIS_90_DAYS; + return true; + } + if (strcmp(value, "all") == 0) { + *period = TRAINLOG_WEB_ANALYSIS_ALL; + return true; + } + return false; +} + +static bool valid_measurement_metric(const char *value) { + static const char *const VALUES[] = {"weight", + "neck", + "shoulders", + "chest", + "waist", + "hips", + "left_arm", + "right_arm", + "left_forearm", + "right_forearm", + "left_thigh", + "right_thigh", + "left_calf", + "right_calf"}; + size_t index; + for (index = 0U; index < sizeof(VALUES) / sizeof(VALUES[0]); ++index) { + if (strcmp(value, VALUES[index]) == 0) { + return true; + } + } + return false; +} + bool trainlog_web_sync_accepted_serialize(const char *request_id, const char *run_id, char *output, @@ -1263,6 +1349,55 @@ static enum MHD_Result handle_request(void *closure, context->csrf_token, "/api/v1/sync/status"); } + if (strcmp(url, "/api/v1/analysis") == 0) { + TrainlogAnalysisArguments arguments = {.valid = true}; + TrainlogWebAnalysisQuery query; + const char *period; + const char *exercise_id; + const char *metric; + const char *page; + char json[TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY]; + size_t json_size = 0U; + time_t now; + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } + (void)MHD_get_connection_values( + connection, MHD_GET_ARGUMENT_KIND, validate_analysis_argument, &arguments); + period = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "period"); + exercise_id = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "exercise_id"); + metric = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "metric"); + page = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "page"); + if (!arguments.valid || !parse_analysis_period(period, &query.period) || + (exercise_id != NULL && !valid_exercise_id(exercise_id)) || + (metric != NULL && !valid_measurement_metric(metric)) || + (page != NULL && strcmp(page, "1") != 0)) { + return queue_json( + connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_analysis_query\"}\n", NULL); + } + now = time(NULL); + if (now == (time_t)-1 || (int64_t)now != (int64_t)(time_t)now) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"clock_unavailable\"}\n", + NULL); + } + query.reference_unix_second = (int64_t)now; + query.exercise_id = exercise_id; + query.measurement_metric = metric == NULL ? "weight" : metric; + if (trainlog_web_analysis_json(context->database, &query, json, sizeof(json), &json_size) != + TRAINLOG_STATUS_OK || + json_size == 0U) { + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"analysis_unavailable\"}\n", + NULL); + } + return queue_json(connection, MHD_HTTP_OK, json, NULL); + } if (strcmp(url, "/api/v1/dashboard") == 0) { TrainlogWebDashboardQuery query; TrainlogWebDashboardSnapshot snapshot; diff --git a/tui/tests/test_web_analysis.c b/tui/tests/test_web_analysis.c new file mode 100644 index 0000000..51648b5 --- /dev/null +++ b/tui/tests/test_web_analysis.c @@ -0,0 +1,238 @@ +#include +#include +#include +#include + +#include +#include + +#include "database_internal.h" +#include "trainlog/web_analysis.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static bool read_snapshot(TrainlogDatabase *database, + TrainlogWebAnalysisPeriod period, + const char *exercise_id, + const char *metric, + yyjson_doc **document) { + TrainlogWebAnalysisQuery query = { + .period = period, + .reference_unix_second = INT64_C(1789560000), + .exercise_id = exercise_id, + .measurement_metric = metric, + }; + char json[TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY]; + size_t size = 0U; + TrainlogStatus status; + + status = trainlog_web_analysis_json(database, &query, json, sizeof(json), &size); + if (status != TRAINLOG_STATUS_OK || size == 0U) { + (void)fprintf(stderr, "analysis status=%d size=%zu\n", (int)status, size); + return false; + } + *document = yyjson_read(json, size, 0U); + return *document != NULL; +} + +static bool insert_session(TrainlogDatabase *database, + const char *session_id, + const char *entry_id, + const char *exercise_id, + const char *started_at, + TrainlogTrackingMode tracking, + TrainlogRecordingMode recording, + TrainlogLoadMode load, + bool explicit_max) { + TrainlogSessionInput session; + TrainlogSessionExerciseInput occurrence; + TrainlogSetInput sets[2] = { + {.reps = 8, .has_weight = load == TRAINLOG_LOAD_EXTERNAL, .weight_kg = 40.0}, + {.reps = 10, .has_weight = load == TRAINLOG_LOAD_EXTERNAL, .weight_kg = 42.0}, + }; + + (void)memset(&session, 0, sizeof(session)); + (void)memset(&occurrence, 0, sizeof(occurrence)); + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + (void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id); + (void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", exercise_id); + session.session_type = explicit_max ? TRAINLOG_SESSION_MAX_TEST : TRAINLOG_SESSION_TRAINING; + session.exercises = &occurrence; + session.exercise_count = 1U; + occurrence.tracking_mode = tracking; + occurrence.recording_mode = recording; + occurrence.load_mode = load; + if (explicit_max) { + occurrence.has_max_weight = true; + occurrence.max_weight_kg = 100.0; + } else if (recording == TRAINLOG_RECORDING_CONTINUOUS) { + occurrence.continuous_duration_seconds = 1200; + occurrence.continuous_distance_km = 3.0; + occurrence.continuous_speed_kmh = 9.0; + } else { + occurrence.sets = sets; + occurrence.set_count = 2U; + } + return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK; +} + +static bool test_empty_and_periods(void) { + static const TrainlogWebAnalysisPeriod PERIODS[] = { + TRAINLOG_WEB_ANALYSIS_7_DAYS, + TRAINLOG_WEB_ANALYSIS_30_DAYS, + TRAINLOG_WEB_ANALYSIS_90_DAYS, + TRAINLOG_WEB_ANALYSIS_ALL, + }; + TrainlogDatabase *database = NULL; + size_t index; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + for (index = 0U; index < sizeof(PERIODS) / sizeof(PERIODS[0]); ++index) { + yyjson_doc *document = NULL; + yyjson_val *root; + CHECK(read_snapshot(database, PERIODS[index], NULL, "weight", &document)); + root = yyjson_doc_get_root(document); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(root, "overview"), "sessions")) == 0); + CHECK(yyjson_is_null(yyjson_obj_get(root, "exercise"))); + CHECK(yyjson_is_null(yyjson_obj_get(root, "active_program"))); + yyjson_doc_free(document); + } + trainlog_database_close(database); + return true; +} + +static bool test_factual_models(void) { + static const char LOADED[] = "ex_10000000-0000-4000-8000-000000000001"; + static const char UNLOADED[] = "ex_10000000-0000-4000-8000-000000000002"; + static const char DURATION[] = "ex_10000000-0000-4000-8000-000000000003"; + const char *none[] = {NULL}; + TrainlogDatabase *database = NULL; + TrainlogBodyObservationInput observation; + yyjson_doc *document = NULL; + yyjson_val *root; + yyjson_val *exercise; + yyjson_val *measurements; + yyjson_val *summaries; + yyjson_val *waist; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + LOADED, + "Loaded", + "loaded", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + UNLOADED, + "Unloaded", + "unloaded", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + 0) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise_profiled(database, + DURATION, + "Duration", + "duration", + TRAINLOG_TRACKING_DURATION, + TRAINLOG_RECORDING_CONTINUOUS, + 3) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, LOADED, "chest", none, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, UNLOADED, "arms", none, 0U) == + TRAINLOG_STATUS_OK); + CHECK(trainlog_database_replace_exercise_body_zones(database, DURATION, "thighs", none, 0U) == + TRAINLOG_STATUS_OK); + CHECK(insert_session(database, + "se_10000000-0000-4000-8000-000000000001", + "en_10000000-0000-4000-8000-000000000001", + LOADED, + "2026-09-15T10:00:00Z", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + TRAINLOG_LOAD_EXTERNAL, + false)); + CHECK(insert_session(database, + "se_10000000-0000-4000-8000-000000000002", + "en_10000000-0000-4000-8000-000000000002", + UNLOADED, + "2026-09-14T10:00:00Z", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + TRAINLOG_LOAD_NONE, + false)); + CHECK(insert_session(database, + "se_10000000-0000-4000-8000-000000000003", + "en_10000000-0000-4000-8000-000000000003", + DURATION, + "2026-09-13T10:00:00Z", + TRAINLOG_TRACKING_DURATION, + TRAINLOG_RECORDING_CONTINUOUS, + TRAINLOG_LOAD_NONE, + false)); + CHECK(insert_session(database, + "se_10000000-0000-4000-8000-000000000004", + "en_10000000-0000-4000-8000-000000000004", + LOADED, + "2026-09-12T10:00:00Z", + TRAINLOG_TRACKING_REPS, + TRAINLOG_RECORDING_SETS, + TRAINLOG_LOAD_EXTERNAL, + true)); + + (void)memset(&observation, 0, sizeof(observation)); + (void)snprintf(observation.observation_id, sizeof(observation.observation_id), "bo_analysis_1"); + (void)snprintf( + observation.observed_at, sizeof(observation.observed_at), "2026-09-10T08:00:00Z"); + observation.has_body_weight = true; + observation.body_weight_kg = 80.0; + observation.has_waist = true; + observation.waist_cm = 90.0; + CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK); + (void)snprintf(observation.observation_id, sizeof(observation.observation_id), "bo_analysis_2"); + (void)snprintf( + observation.observed_at, sizeof(observation.observed_at), "2026-09-15T08:00:00Z"); + observation.body_weight_kg = 79.5; + observation.waist_cm = 88.0; + CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK); + + CHECK( + sqlite3_exec(database->connection, + "INSERT INTO programs VALUES('pg_analysis','Program',NULL,'active',NULL,NULL," + "'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1," + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',NULL);" + "INSERT INTO program_sessions VALUES('ps_analysis','pg_analysis',0,'Next'," + "'training','2026-09-21',NULL);", + NULL, + NULL, + NULL) == SQLITE_OK); + + CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, LOADED, "waist", &document)); + root = yyjson_doc_get_root(document); + CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(root, "overview"), "sessions")) == 4); + exercise = yyjson_obj_get(root, "exercise"); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(exercise, "exercise_id")), LOADED) == 0); + CHECK(yyjson_arr_size(yyjson_obj_get(exercise, "points")) == 2U); + CHECK(yyjson_arr_size(yyjson_obj_get(root, "body_zones")) == 3U); + CHECK(!yyjson_is_null(yyjson_obj_get(root, "active_program"))); + measurements = yyjson_obj_get(root, "measurements"); + CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U); + summaries = yyjson_obj_get(measurements, "summaries"); + waist = yyjson_arr_get(summaries, 4U); + CHECK(yyjson_get_int(yyjson_obj_get(waist, "count")) == 2); + CHECK(yyjson_get_real(yyjson_obj_get(waist, "delta")) == -2.0); + yyjson_doc_free(document); + trainlog_database_close(database); + return true; +} + +int main(void) { + return test_empty_and_periods() && test_factual_models() ? 0 : 1; +} diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 055407a..8e39c42 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -308,7 +308,7 @@ static bool test_http_contract(TrainlogDatabase *database) { CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL); CHECK(strstr(response, "{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\"," - "\"version\":\"0.1.3\"}") != NULL); + "\"version\":\"0.1.4\"}") != NULL); { char token[80]; CHECK(exchange(port, @@ -508,6 +508,26 @@ static bool test_http_contract(TrainlogDatabase *database) { CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL); CHECK(strstr(response, "\"window_days\":90") != NULL); CHECK(strstr(response, "\"partial\":false") != NULL); + CHECK(exchange(port, + "GET /api/v1/analysis?period=30d&metric=waist&page=1 HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"period\":\"30d\"") != NULL && + strstr(response, "\"selected_metric\":\"waist\"") != NULL); + CHECK(exchange(port, + "GET /api/v1/analysis?period=invalid HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 400") != NULL && + strstr(response, "invalid_analysis_query") != NULL); + CHECK(exchange(port, + "GET /api/v1/analysis?period=7d&period=90d HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 400") != NULL); CHECK(exchange(port, "GET /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", response, diff --git a/web/src/analysis/messages.ts b/web/src/analysis/messages.ts new file mode 100644 index 0000000..2a4d17a --- /dev/null +++ b/web/src/analysis/messages.ts @@ -0,0 +1,28 @@ +export type AnalysisLanguage = 'fr' | 'en' + +export const analysisMessages = { + fr: { + eyebrow: 'DONNÉES FACTUELLES', title: 'Analyse', loading: 'Chargement de l’analyse…', failed: 'Analyse momentanément indisponible.', + overview: 'Vue d’ensemble', exercises: 'Exercices', distribution: 'Répartition', measurements: 'Mensurations', + period: 'Période', periods: { '7d': '7 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' }, + sessions: 'Séances', sets: 'Séries', duration: 'Durée', unavailable: 'Indisponible', noData: 'Aucune donnée sur cette période.', + activeProgram: 'Programme actif', noProgram: 'Aucun programme actif', nextSession: 'Prochaine séance', + exercise: 'Exercice', noExercise: 'Aucun exercice disponible', noExerciseData: 'Aucune donnée compatible pour cet exercice.', + reps: 'Répétitions', volume: 'Volume externe', max: 'MAX explicite', frequency: 'Fréquence', distance: 'Distance', speed: 'Vitesse', + exposures: 'Expositions', zonesHelp: 'Occurrences enregistrées associées à une zone primaire. Ce ne sont pas des pourcentages physiologiques.', + metric: 'Mesure', first: 'Première', last: 'Dernière', delta: 'Évolution', onePoint: 'Une seule mesure : aucune évolution calculée.', + language: 'Langue', partial: 'Affichage borné aux observations les plus récentes.', programProgress: 'Progression du programme', + }, + en: { + eyebrow: 'FACTUAL DATA', title: 'Analysis', loading: 'Loading analysis…', failed: 'Analysis is temporarily unavailable.', + overview: 'Overview', exercises: 'Exercises', distribution: 'Distribution', measurements: 'Measurements', + period: 'Period', periods: { '7d': '7 days', '30d': '30 days', '90d': '90 days', all: 'All' }, + sessions: 'Sessions', sets: 'Sets', duration: 'Duration', unavailable: 'Unavailable', noData: 'No data in this period.', + activeProgram: 'Active program', noProgram: 'No active program', nextSession: 'Next session', + exercise: 'Exercise', noExercise: 'No exercise available', noExerciseData: 'No compatible data for this exercise.', + reps: 'Repetitions', volume: 'External volume', max: 'Explicit MAX', frequency: 'Frequency', distance: 'Distance', speed: 'Speed', + exposures: 'Exposures', zonesHelp: 'Recorded occurrences associated with a primary zone. These are not physiological percentages.', + metric: 'Measurement', first: 'First', last: 'Last', delta: 'Change', onePoint: 'Only one measurement: no change calculated.', + language: 'Language', partial: 'Display is bounded to the most recent observations.', programProgress: 'Program progress', + }, +} as const diff --git a/web/src/api/analysis.test.ts b/web/src/api/analysis.test.ts new file mode 100644 index 0000000..0c660cd --- /dev/null +++ b/web/src/api/analysis.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchAnalysis, parseAnalysis, type AnalysisSnapshot } from './analysis' + +export const analysisFixture: AnalysisSnapshot = { + api_version: 1, + period: '30d', + overview: { sessions: 2, sets: 4, duration_seconds: null }, + activity: [{ date: '2026-09-20', sessions: 2, sets: 4 }], + exercises: [{ exercise_id: 'ex_10000000-0000-4000-8000-000000000001', name: 'Presse', tracking_mode: 'reps', recording_mode: 'sets' }], + exercise: { + exercise_id: 'ex_10000000-0000-4000-8000-000000000001', name: 'Presse', tracking_mode: 'reps', recording_mode: 'sets', + points: [{ session_id: 'se_10000000-0000-4000-8000-000000000001', timestamp: '2026-09-20T08:00:00Z', load_mode: 'external', sets: 2, reps: 18, set_duration_seconds: null, external_load_kg: 42, external_volume_kg: 756, continuous_duration_seconds: null, distance_km: null, speed_kmh: null, explicit_max_kg: 100 }], + }, + body_zones: [{ zone_id: 'thighs', label: 'Cuisses', exposures: 1, associated_sets: 2 }], + measurements: { + selected_metric: 'waist', unit: 'cm', points: [{ timestamp: '2026-09-20T08:00:00Z', value: 88 }], + summaries: [ + { metric: 'weight', unit: 'kg', count: 0, first: null, last: null, delta: null }, + { metric: 'neck', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'shoulders', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'chest', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'waist', unit: 'cm', count: 1, first: 88, last: 88, delta: null }, + { metric: 'hips', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'left_arm', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'right_arm', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'left_forearm', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'right_forearm', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'left_thigh', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'right_thigh', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'left_calf', unit: 'cm', count: 0, first: null, last: null, delta: null }, + { metric: 'right_calf', unit: 'cm', count: 0, first: null, last: null, delta: null }, + ], + }, + active_program: { program_id: 'pg_test', title: 'Programme', total_sessions: 4, completed_sessions: 1, next_session_title: 'Séance B' }, + meta: { partial: false, reference_unix_second: 1789560000 }, +} + +afterEach(() => vi.restoreAllMocks()) + +describe('analysis API', () => { + it('accepts persisted nulls without inventing zeroes', () => { + expect(parseAnalysis(analysisFixture).overview.duration_seconds).toBeNull() + expect(parseAnalysis(analysisFixture).measurements.summaries[4].delta).toBeNull() + }) + + it('sends validated bounded query parameters', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(analysisFixture), { status: 200 })) + await fetchAnalysis({ period: '90d', exerciseId: analysisFixture.exercises[0].exercise_id, metric: 'waist' }) + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('period=90d'), expect.anything()) + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('page=1'), expect.anything()) + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('metric=waist'), expect.anything()) + }) + + it('rejects unbounded arrays', () => { + expect(() => parseAnalysis({ ...analysisFixture, activity: Array.from({ length: 91 }, () => analysisFixture.activity[0]) })).toThrow('analysis_activity_invalid') + }) +}) diff --git a/web/src/api/analysis.ts b/web/src/api/analysis.ts new file mode 100644 index 0000000..0aaee24 --- /dev/null +++ b/web/src/api/analysis.ts @@ -0,0 +1,84 @@ +export type AnalysisPeriod = '7d' | '30d' | '90d' | 'all' +export type MeasurementMetric = 'weight' | 'neck' | 'shoulders' | 'chest' | 'waist' | 'hips' | + 'left_arm' | 'right_arm' | 'left_forearm' | 'right_forearm' | + 'left_thigh' | 'right_thigh' | 'left_calf' | 'right_calf' + +export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '30d', '90d', 'all'] +export const MEASUREMENT_METRICS: readonly MeasurementMetric[] = [ + 'weight', 'neck', 'shoulders', 'chest', 'waist', 'hips', 'left_arm', 'right_arm', + 'left_forearm', 'right_forearm', 'left_thigh', 'right_thigh', 'left_calf', 'right_calf', +] + +export interface AnalysisActivityDay { date: string; sessions: number; sets: number } +export interface AnalysisExerciseOption { exercise_id: string; name: string; tracking_mode: 'reps' | 'duration'; recording_mode: 'sets' | 'continuous' } +export interface AnalysisExercisePoint { + session_id: string; timestamp: string; load_mode: 'none' | 'external' | 'assistance' + sets: number; reps: number | null; set_duration_seconds: number | null + external_load_kg: number | null; external_volume_kg: number | null; continuous_duration_seconds: number | null + distance_km: number | null; speed_kmh: number | null; explicit_max_kg: number | null +} +export interface AnalysisExercise extends AnalysisExerciseOption { points: AnalysisExercisePoint[] } +export interface AnalysisZone { zone_id: string; label: string; exposures: number; associated_sets: number } +export interface MeasurementSummary { metric: MeasurementMetric; unit: 'kg' | 'cm'; count: number; first: number | null; last: number | null; delta: number | null } +export interface MeasurementPoint { timestamp: string; value: number } +export interface ActiveProgram { program_id: string; title: string; total_sessions: number; completed_sessions: number; next_session_title: string | null } + +export interface AnalysisSnapshot { + api_version: 1 + period: AnalysisPeriod + overview: { sessions: number; sets: number; duration_seconds: number | null } + activity: AnalysisActivityDay[] + exercises: AnalysisExerciseOption[] + exercise: AnalysisExercise | null + body_zones: AnalysisZone[] + measurements: { summaries: MeasurementSummary[]; selected_metric: MeasurementMetric; unit: 'kg' | 'cm'; points: MeasurementPoint[] } + active_program: ActiveProgram | null + meta: { partial: boolean; reference_unix_second: number } +} + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) +const count = (value: unknown): value is number => finite(value) && Number.isSafeInteger(value) && value >= 0 +const nullableFinite = (value: unknown): value is number | null => value === null || finite(value) +const period = (value: unknown): value is AnalysisPeriod => typeof value === 'string' && ANALYSIS_PERIODS.includes(value as AnalysisPeriod) +const metric = (value: unknown): value is MeasurementMetric => typeof value === 'string' && MEASUREMENT_METRICS.includes(value as MeasurementMetric) + +/** CONTRACT: malformed or unbounded API shapes are rejected before rendering; + * missing observations remain null and are never coerced to zero. */ +export function parseAnalysis(value: unknown): AnalysisSnapshot { + if (!record(value) || value.api_version !== 1 || !period(value.period) || !record(value.overview) || + !Array.isArray(value.activity) || !Array.isArray(value.exercises) || !Array.isArray(value.body_zones) || + !record(value.measurements) || !record(value.meta)) throw new TypeError('analysis_invalid') + if (!count(value.overview.sessions) || !count(value.overview.sets) || !nullableFinite(value.overview.duration_seconds)) throw new TypeError('analysis_overview_invalid') + if (value.activity.length > 90 || !value.activity.every((item) => record(item) && typeof item.date === 'string' && count(item.sessions) && count(item.sets))) throw new TypeError('analysis_activity_invalid') + if (value.exercises.length > 128 || !value.exercises.every((item) => record(item) && typeof item.exercise_id === 'string' && typeof item.name === 'string' && (item.tracking_mode === 'reps' || item.tracking_mode === 'duration') && (item.recording_mode === 'sets' || item.recording_mode === 'continuous'))) throw new TypeError('analysis_exercises_invalid') + if (value.exercise !== null) { + if (!record(value.exercise) || typeof value.exercise.exercise_id !== 'string' || typeof value.exercise.name !== 'string' || + (value.exercise.tracking_mode !== 'reps' && value.exercise.tracking_mode !== 'duration') || + (value.exercise.recording_mode !== 'sets' && value.exercise.recording_mode !== 'continuous') || + !Array.isArray(value.exercise.points) || value.exercise.points.length > 90 || + !value.exercise.points.every((point) => record(point) && typeof point.session_id === 'string' && typeof point.timestamp === 'string' && + (point.load_mode === 'none' || point.load_mode === 'external' || point.load_mode === 'assistance') && count(point.sets) && + nullableFinite(point.reps) && nullableFinite(point.set_duration_seconds) && nullableFinite(point.external_load_kg) && nullableFinite(point.external_volume_kg) && + nullableFinite(point.continuous_duration_seconds) && nullableFinite(point.distance_km) && nullableFinite(point.speed_kmh) && nullableFinite(point.explicit_max_kg))) throw new TypeError('analysis_exercise_invalid') + } + if (!value.body_zones.every((item) => record(item) && typeof item.zone_id === 'string' && typeof item.label === 'string' && count(item.exposures) && count(item.associated_sets))) throw new TypeError('analysis_zones_invalid') + const summaries = value.measurements.summaries + const points = value.measurements.points + if (!Array.isArray(summaries) || summaries.length !== MEASUREMENT_METRICS.length || !summaries.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') && count(item.count) && nullableFinite(item.first) && nullableFinite(item.last) && nullableFinite(item.delta)) || + !metric(value.measurements.selected_metric) || (value.measurements.unit !== 'kg' && value.measurements.unit !== 'cm') || !Array.isArray(points) || points.length > 90 || !points.every((item) => record(item) && typeof item.timestamp === 'string' && finite(item.value))) throw new TypeError('analysis_measurements_invalid') + if (value.active_program !== null && (!record(value.active_program) || typeof value.active_program.program_id !== 'string' || typeof value.active_program.title !== 'string' || !count(value.active_program.total_sessions) || !count(value.active_program.completed_sessions) || !(value.active_program.next_session_title === null || typeof value.active_program.next_session_title === 'string'))) throw new TypeError('analysis_program_invalid') + if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid') + return value as unknown as AnalysisSnapshot +} + +export interface AnalysisQuery { period?: AnalysisPeriod; exerciseId?: string; metric?: MeasurementMetric } + +export async function fetchAnalysis(query: AnalysisQuery = {}, signal?: AbortSignal): Promise { + const parameters = new URLSearchParams({ period: query.period ?? '30d', page: '1' }) + if (query.exerciseId) parameters.set('exercise_id', query.exerciseId) + if (query.metric) parameters.set('metric', query.metric) + const response = await fetch(`/api/v1/analysis?${parameters}`, { headers: { Accept: 'application/json' }, signal }) + if (!response.ok) throw new Error(`analysis HTTP ${response.status}`) + return parseAnalysis(await response.json()) +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 071cb9e..c893886 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -9,12 +9,13 @@ import { PlaceholderPage } from '../routes/PlaceholderPage' import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage' import { SessionsPage } from '../routes/SessionsPage' import { ExercisesPage } from '../routes/ExercisesPage' +import { AnalysisPage } from '../routes/AnalysisPage' import type { RouteId } from './routes' import { useRoute } from './useRoute' import { DatePreferencesProvider } from '../presentation/DatePreferences' +import { fetchAnalysis, type AnalysisSnapshot } from '../api/analysis' -const placeholderContent: Record, [string, string, string]> = { - analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'], +const placeholderContent: Record, [string, string, string]> = { sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'], } @@ -29,6 +30,7 @@ function AppContent() { const [preparedItems, setPreparedItems] = useState(null) const [preparedItemsPending, setPreparedItemsPending] = useState(true) const [preparedItemsFailed, setPreparedItemsFailed] = useState(false) + const [analysis, setAnalysis] = useState(null) const reloadDashboard = useCallback(() => { fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) }) .catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false)) @@ -62,6 +64,7 @@ function AppContent() { }).catch(() => { setPreparedItemsFailed(true) }).finally(() => setPreparedItemsPending(false)) + fetchAnalysis({}, controller.signal).then(setAnalysis).catch(() => setAnalysis(null)) return () => controller.abort() }, []) @@ -73,8 +76,10 @@ function AppContent() { preparedItems={preparedItems} preparedItemsPending={preparedItemsPending} preparedItemsFailed={preparedItemsFailed} + analysis={analysis} /> - ) : route.id === 'sessions' ? : + ) : route.id === 'analysis' ? : + route.id === 'sessions' ? : route.id === 'programs' ? : route.id === 'exercises' ? : { expect(screen.getByText('Identité comparable : reps · external')).toBeInTheDocument() fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowLeft', shiftKey: true }) expect(screen.queryByText('Identité comparable : reps · external')).not.toBeInTheDocument() - expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(1) - fireEvent.keyDown(screen.getByRole('article', { name: 'Records / MAX, tuile modifiable' }), { key: 'ArrowDown', shiftKey: true }) - expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(3) + expect(screen.getByRole('article', { name: 'Progression, tuile modifiable' })).toHaveAttribute('data-size', 'medium') + fireEvent.keyDown(screen.getByRole('article', { name: 'Mensurations, tuile modifiable' }), { key: 'ArrowDown', shiftKey: true }) + expect(screen.getByRole('article', { name: 'Mensurations, tuile modifiable' })).toHaveAttribute('data-size', 'medium') }) it('signale discrètement loading, erreur globale et invalid_data', async () => { diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx index 97e0d22..18896f2 100644 --- a/web/src/dashboard/DashboardGrid.tsx +++ b/web/src/dashboard/DashboardGrid.tsx @@ -18,16 +18,18 @@ import { type TileId, type TileLayout, } from './dashboardLayout' -import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles' +import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles' +import { MeasurementsTile, ProgramTile } from './DashboardTiles' +import type { AnalysisSnapshot } from '../api/analysis' const dashboardTiles: Readonly> = { 'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile }, activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile }, progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile }, 'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile }, - 'max-records': { title: 'Records / MAX', eyebrow: 'Performances', component: MaxRecordsTile }, + 'max-records': { title: 'Mensurations', eyebrow: 'Évolution', component: MeasurementsTile }, 'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile }, - 'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', component: CardioTile }, + 'cardio-recovery': { title: 'Programme actif', eyebrow: 'Planification', component: ProgramTile }, } function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } { @@ -43,6 +45,7 @@ interface DashboardGridProps { preparedItems?: PreparedItemsSnapshot | null preparedItemsPending?: boolean preparedItemsFailed?: boolean + analysis?: AnalysisSnapshot | null } export function DashboardGrid({ @@ -52,6 +55,7 @@ export function DashboardGrid({ preparedItems = null, preparedItemsPending = false, preparedItemsFailed = false, + analysis = null, }: DashboardGridProps) { const [layout, setLayout] = useState(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [editing, setEditing] = useState(false) @@ -186,7 +190,7 @@ export function DashboardGrid({ const size = tileSize(dimensions) const available = id === 'next-session' ? (preparedItems?.items.length ?? 0) > 0 - : dashboard !== null && tileAvailable(dashboard, id) + : dashboard !== null && tileAvailable(dashboard, id, analysis) const tilePending = id === 'next-session' ? preparedItemsPending : pending const partial = dashboard?.meta.partial === true && id === 'max-records' const TileContent = content.component @@ -211,7 +215,7 @@ export function DashboardGrid({ ) : dashboard === null ? ( ) : ( - + )} @@ -223,12 +227,12 @@ export function DashboardGrid({ ) } -function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean { +function tileAvailable(snapshot: DashboardSnapshot, id: TileId, analysis: AnalysisSnapshot | null): boolean { if (id === 'next-session') return false if (id === 'activity') return snapshot.data.activity.available if (id === 'progression') return snapshot.data.progression.available if (id === 'last-session') return snapshot.data.last_session.available - if (id === 'max-records') return snapshot.data.max_records.available + if (id === 'max-records') return analysis?.measurements.summaries.some((item) => item.last !== null) === true if (id === 'muscle-distribution') return snapshot.data.muscle_distribution.available - return snapshot.data.cardio.available + return analysis?.active_program !== null && analysis?.active_program !== undefined } diff --git a/web/src/dashboard/DashboardTiles.tsx b/web/src/dashboard/DashboardTiles.tsx index bb31769..c11e6b4 100644 --- a/web/src/dashboard/DashboardTiles.tsx +++ b/web/src/dashboard/DashboardTiles.tsx @@ -7,10 +7,11 @@ import type { PreparedItemsSnapshot } from '../api/preparedItems' import type { PreparedItem } from '../api/preparedItems' import { useDatePreferences } from '../presentation/DatePreferences' import { validDateSortValue, validTimestampValue } from '../presentation/dateFormat' +import type { AnalysisSnapshot } from '../api/analysis' const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart }))) -interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize } +interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize; analysis?: AnalysisSnapshot | null } export interface NextSessionTileProps { size: TileSize @@ -99,21 +100,22 @@ export function NextSessionTile({ )} {failed && Dernière lecture conservée · actualisation indisponible} {pending && !failed && Actualisation…} + Ouvrir la séance ) } -export function ActivityTile({ snapshot, size }: TileDataProps) { +export function ActivityTile({ snapshot, size, analysis }: TileDataProps) { const { dateFormat } = useDatePreferences() const days = snapshot.data.activity.days // CONTRACT: these are direct presentation totals of Core-projected counters, // never an activity or physiological-intensity score. const activeDays = days.filter((day) => day.active).length - const sessions = days.reduce((total, day) => total + day.session_count, 0) - const sets = days.reduce((total, day) => total + day.set_count, 0) + const sessions = analysis?.overview.sessions ?? days.reduce((total, day) => total + day.session_count, 0) + const sets = analysis?.overview.sets ?? days.reduce((total, day) => total + day.set_count, 0) const shown = size === 'compact' ? days.slice(-35) : days return
-
{size !== 'compact' && }{size === 'large' && }
+
{size !== 'compact' && }{size === 'large' && }{size === 'large' && analysis?.overview.duration_seconds !== null && analysis?.overview.duration_seconds !== undefined && }
{shown.map((day) => 1 ? ' is-multiple' : ''}`} title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, 'séance')}, ${count(day.set_count, 'série')}`} />)}
@@ -121,8 +123,13 @@ export function ActivityTile({ snapshot, size }: TileDataProps) {
} -export function ProgressionTile({ snapshot, size }: TileDataProps) { +export function ProgressionTile({ snapshot, size, analysis }: TileDataProps) { const { dateFormat } = useDatePreferences() + if (analysis?.exercise !== null && analysis?.exercise !== undefined) { + const exercise = analysis.exercise + const latest = exercise.points[0] + return

{exercise.name}

{exercise.tracking_mode === 'reps' ? 'Répétitions' : 'Durée'}

{latest === undefined ? :
{latest.reps !== null && }{latest.external_volume_kg !== null && }{latest.explicit_max_kg !== null && }
}Voir dans Analyse
+ } const progression = snapshot.data.progression if (!progression.available) return const first = progression.points[0] @@ -167,7 +174,7 @@ export function MuscleDistributionTile({ snapshot, size }: TileDataProps) { const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1) return
{size !== 'compact' &&

Couleur : séances sur 30 jours · maximum relatif au snapshot affiché

} -

Longueur : nombre de séances sur 30 jours

    {shown.map((zone) => )}
+

Longueur : nombre de séances sur 30 jours

    {shown.map((zone) => )}
Voir dans Analyse
} @@ -179,6 +186,19 @@ export function CardioTile({ size }: TileDataProps) { return
{size !== 'compact' &&

Aucune donnée cardio disponible

}{size === 'large' && Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.}
} +export function ProgramTile({ size, analysis }: TileDataProps) { + const program = analysis?.active_program + if (program === null || program === undefined) return + return
{program.title}
{program.completed_sessions}/{program.total_sessions} séances effectuées
{size !== 'compact' &&

{program.next_session_title === null ? 'Programme terminé' : `Prochaine séance : ${program.next_session_title}`}

}Ouvrir Programmes
+} + +export function MeasurementsTile({ size, analysis }: TileDataProps) { + const summaries = analysis?.measurements.summaries ?? [] + const visible = summaries.filter((summary) => summary.metric === 'weight' || summary.metric === 'waist').filter((summary) => summary.last !== null) + if (visible.length === 0) return + return
{visible.map((summary) =>
{summary.metric === 'weight' ? 'Poids' : 'Tour de taille'}
{formatWeight(summary.last ?? 0).replace('kg', summary.unit)}{summary.delta !== null && size !== 'compact' && {summary.delta > 0 ? '+' : ''}{summary.delta.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} {summary.unit}}
)}
Voir dans Analyse
+} + function Metric({ value, label }: { value: string; label: string }) { return
{value}{label}
} function Fact({ label, value }: { label: string; value: ReactNode }) { return
{label}
{value}
} function Unavailable({ text }: { text: string }) { return

{text}

} diff --git a/web/src/routes/AnalysisPage.test.tsx b/web/src/routes/AnalysisPage.test.tsx new file mode 100644 index 0000000..5af08ac --- /dev/null +++ b/web/src/routes/AnalysisPage.test.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AnalysisPage } from './AnalysisPage' +import { analysisFixture } from '../api/analysis.test' + +describe('AnalysisPage', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(analysisFixture), { status: 200 })) + }) + afterEach(() => vi.restoreAllMocks()) + + it('falls back safely and changes sections, periods and metrics', async () => { + render() + expect(await screen.findByRole('heading', { name: 'Analyse' })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Vue d’ensemble' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByLabelText('Période')).toHaveValue('30d') + fireEvent.change(screen.getByLabelText('Période'), { target: { value: '7d' } }) + await waitFor(() => expect(globalThis.fetch).toHaveBeenLastCalledWith(expect.stringContaining('period=7d'), expect.anything())) + fireEvent.click(screen.getByRole('tab', { name: 'Mensurations' })) + fireEvent.change(screen.getByLabelText('Mesure'), { target: { value: 'waist' } }) + expect(window.location.search).toContain('section=measurements') + }) + + it('supports exercise and BODY ZONE deep-link sections and English UI', async () => { + window.history.replaceState(null, '', `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`) + render() + expect(await screen.findByRole('heading', { name: 'Presse' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: 'Répartition' })) + expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Langue'), { target: { value: 'en' } }) + expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument() + expect(screen.getByText(/not physiological percentages/)).toBeInTheDocument() + }) +}) diff --git a/web/src/routes/AnalysisPage.tsx b/web/src/routes/AnalysisPage.tsx new file mode 100644 index 0000000..10f7df9 --- /dev/null +++ b/web/src/routes/AnalysisPage.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useState } from 'react' +import { ANALYSIS_PERIODS, MEASUREMENT_METRICS, fetchAnalysis, type AnalysisExercisePoint, type AnalysisPeriod, type AnalysisSnapshot, type MeasurementMetric } from '../api/analysis' +import { BodyZoneFigure } from '../dashboard/BodyZoneFigure' +import { analysisMessages, type AnalysisLanguage } from '../analysis/messages' + +type AnalysisSection = 'overview' | 'exercise' | 'body-zones' | 'measurements' +const sections: readonly AnalysisSection[] = ['overview', 'exercise', 'body-zones', 'measurements'] +const exerciseIdPattern = /^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const metricLabels = { + fr: { weight: 'Poids', neck: 'Cou', shoulders: 'Épaules', chest: 'Poitrine', waist: 'Taille', hips: 'Hanches', left_arm: 'Bras gauche', right_arm: 'Bras droit', left_forearm: 'Avant-bras gauche', right_forearm: 'Avant-bras droit', left_thigh: 'Cuisse gauche', right_thigh: 'Cuisse droite', left_calf: 'Mollet gauche', right_calf: 'Mollet droit' }, + en: { weight: 'Weight', neck: 'Neck', shoulders: 'Shoulders', chest: 'Chest', waist: 'Waist', hips: 'Hips', left_arm: 'Left arm', right_arm: 'Right arm', left_forearm: 'Left forearm', right_forearm: 'Right forearm', left_thigh: 'Left thigh', right_thigh: 'Right thigh', left_calf: 'Left calf', right_calf: 'Right calf' }, +} satisfies Record> + +function safeInitialQuery() { + const query = new URLSearchParams(window.location.search) + const sectionValue = query.get('section') + const periodValue = query.get('period') + const metricValue = query.get('metric') + const exerciseValue = query.get('exercise_id') + return { + section: sections.includes(sectionValue as AnalysisSection) ? sectionValue as AnalysisSection : 'overview', + period: ANALYSIS_PERIODS.includes(periodValue as AnalysisPeriod) ? periodValue as AnalysisPeriod : '30d', + metric: MEASUREMENT_METRICS.includes(metricValue as MeasurementMetric) ? metricValue as MeasurementMetric : 'weight', + exerciseId: exerciseValue !== null && exerciseIdPattern.test(exerciseValue) ? exerciseValue : undefined, + } +} + +function formatNumber(value: number, language: AnalysisLanguage, maximumFractionDigits = 2) { + return new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : 'en-US', { maximumFractionDigits }).format(value) +} + +function duration(value: number, language: AnalysisLanguage) { + const hours = Math.floor(value / 3600) + const minutes = Math.floor((value % 3600) / 60) + return hours > 0 ? `${hours} h ${minutes.toString().padStart(2, '0')} min` : `${minutes} min${language === 'en' ? '' : ''}` +} + +function MiniLineChart({ points, unit, language, label }: { points: Array<{ timestamp: string; value: number }>; unit: string; language: AnalysisLanguage; label: string }) { + if (points.length === 0) return null + const ordered = [...points].reverse() + const values = ordered.map((point) => point.value) + const minimum = Math.min(...values) + const maximum = Math.max(...values) + const span = maximum - minimum || 1 + const polyline = ordered.map((point, index) => `${ordered.length === 1 ? 50 : (index / (ordered.length - 1)) * 100},${36 - ((point.value - minimum) / span) * 30}`).join(' ') + return
{ordered.map((point, index) => {new Intl.DateTimeFormat(language === 'fr' ? 'fr-FR' : 'en-US').format(new Date(point.timestamp))} · {formatNumber(point.value, language)} {unit})}
{formatNumber(minimum, language)}–{formatNumber(maximum, language)} {unit} · {points.length} points · no interpolation
+} + +function ExerciseFacts({ points, language }: { points: AnalysisExercisePoint[]; language: AnalysisLanguage }) { + const t = analysisMessages[language] + if (points.length === 0) return

{t.noExerciseData}

+ const latest = points[0] + return <> +
+ + {latest.reps !== null && } + {latest.external_load_kg !== null && } + {latest.external_volume_kg !== null && } + {(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !== null && } + {latest.distance_km !== null && } + {latest.speed_kmh !== null && } + {latest.explicit_max_kg !== null && } + {latest.load_mode === 'none' && latest.reps !== null && } +
+ ({ timestamp: point.timestamp, value: point.explicit_max_kg ?? point.external_volume_kg ?? point.reps ?? point.continuous_duration_seconds ?? point.set_duration_seconds ?? 0 })).filter((point) => point.value > 0)} unit={latest.explicit_max_kg !== null || latest.external_volume_kg !== null ? 'kg' : latest.reps !== null ? 'reps' : 's'} language={language} label={t.exercise} /> + +} + +function Fact({ label, value, unit, language }: { label: string; value: number; unit?: string; language: AnalysisLanguage }) { + return
{label}
{formatNumber(value, language)}{unit ? ` ${unit}` : ''}
+} + +export function AnalysisPage() { + const initial = useMemo(safeInitialQuery, []) + const [section, setSection] = useState(initial.section) + const [period, setPeriod] = useState(initial.period) + const [metric, setMetric] = useState(initial.metric) + const [exerciseId, setExerciseId] = useState(initial.exerciseId) + const [language, setLanguage] = useState('fr') + const [snapshot, setSnapshot] = useState(null) + const [pending, setPending] = useState(true) + const [failed, setFailed] = useState(false) + const t = analysisMessages[language] + + useEffect(() => { + const controller = new AbortController() + setPending(true) + fetchAnalysis({ period, exerciseId, metric }, controller.signal).then((value) => { + setSnapshot(value); setFailed(false) + if (exerciseId === undefined && value.exercise !== null) setExerciseId(value.exercise.exercise_id) + }).catch(() => { if (!controller.signal.aborted) setFailed(true) }).finally(() => { if (!controller.signal.aborted) setPending(false) }) + return () => controller.abort() + }, [period, exerciseId, metric]) + + function updateUrl(nextSection = section, nextPeriod = period, nextExercise = exerciseId, nextMetric = metric) { + const query = new URLSearchParams({ section: nextSection, period: nextPeriod }) + if (nextExercise) query.set('exercise_id', nextExercise) + if (nextMetric !== 'weight' || nextSection === 'measurements') query.set('metric', nextMetric) + window.history.replaceState(null, '', `/analyse?${query}`) + } + function chooseSection(value: AnalysisSection) { setSection(value); updateUrl(value) } + function choosePeriod(value: AnalysisPeriod) { setPeriod(value); updateUrl(section, value) } + function chooseExercise(value: string) { setExerciseId(value); updateUrl('exercise', period, value); setSection('exercise') } + function chooseMetric(value: MeasurementMetric) { setMetric(value); updateUrl('measurements', period, exerciseId, value); setSection('measurements') } + + const selectedSummary = snapshot?.measurements.summaries.find((item) => item.metric === metric) + const zoneFigure = snapshot?.body_zones.map((zone) => ({ zone_id: zone.zone_id, label: zone.label, session_count: zone.exposures, occurrence_count: zone.exposures, set_count: zone.associated_sets })) ?? [] + return
+

{t.eyebrow}

{t.title}

+
{sections.map((id) => )}
+ {pending && snapshot === null &&

{t.loading}

} + {failed && snapshot === null &&

{t.failed}

} + {snapshot !== null && <> + {snapshot.meta.partial &&

{t.partial}

} + {section === 'overview' &&
+

{t.overview}

{snapshot.overview.duration_seconds !== null &&
{t.duration}
{duration(snapshot.overview.duration_seconds, language)}
}
{snapshot.overview.sessions === 0 &&

{t.noData}

} ({ timestamp: `${day.date}T00:00:00Z`, value: day.sessions }))} unit={t.sessions.toLowerCase()} language={language} label={t.sessions} />
+

{t.activeProgram}

{snapshot.active_program === null ?

{t.noProgram}

: <>

{snapshot.active_program.title}

{snapshot.active_program.completed_sessions}/{snapshot.active_program.total_sessions}

{snapshot.active_program.next_session_title &&

{t.nextSession}: {snapshot.active_program.next_session_title}

}Programmes}
+

{t.distribution}

{zoneFigure.length === 0 ?

{t.noData}

: }
+

{t.exercises}

{snapshot.exercise === null ?

{t.noExercise}

: <>

{snapshot.exercise.name}

}
+
} + {section === 'exercise' &&
{snapshot.exercise === null ?

{t.noExercise}

: <>

{snapshot.exercise.name}

}
} + {section === 'body-zones' &&

{t.exposures}

{t.zonesHelp}

{zoneFigure.length === 0 ?

{t.noData}

:
    {snapshot.body_zones.map((zone) =>
  • {zone.label}{zone.exposures} {t.exposures.toLowerCase()} · {zone.associated_sets} {t.sets.toLowerCase()}
  • )}
}
} + {section === 'measurements' &&

{metricLabels[language][metric]}

{selectedSummary === undefined || selectedSummary.count === 0 ?

{t.noData}

: <>
{t.first}
{selectedSummary.first === null ? '—' : `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`}
{t.last}
{selectedSummary.last === null ? '—' : `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`}
{t.delta}
{selectedSummary.delta === null ? '—' : `${selectedSummary.delta > 0 ? '+' : ''}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}
{selectedSummary.count === 1 &&

{t.onePoint}

}}
} + } +
+} diff --git a/web/src/routes/DashboardPage.tsx b/web/src/routes/DashboardPage.tsx index 6452eb1..12d3056 100644 --- a/web/src/routes/DashboardPage.tsx +++ b/web/src/routes/DashboardPage.tsx @@ -1,6 +1,7 @@ import { DashboardGrid } from '../dashboard/DashboardGrid' import type { DashboardSnapshot } from '../api/dashboard' import type { PreparedItemsSnapshot } from '../api/preparedItems' +import type { AnalysisSnapshot } from '../api/analysis' interface DashboardPageProps { dashboard: DashboardSnapshot | null @@ -9,6 +10,7 @@ interface DashboardPageProps { preparedItems: PreparedItemsSnapshot | null preparedItemsPending: boolean preparedItemsFailed: boolean + analysis: AnalysisSnapshot | null } export function DashboardPage({ @@ -18,6 +20,7 @@ export function DashboardPage({ preparedItems, preparedItemsPending, preparedItemsFailed, + analysis, }: DashboardPageProps) { return (
@@ -31,6 +34,7 @@ export function DashboardPage({ preparedItems={preparedItems} preparedItemsPending={preparedItemsPending} preparedItemsFailed={preparedItemsFailed} + analysis={analysis} />
) diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 9d0f22f..96ff4db 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -68,6 +68,43 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.analysis-toolbar { margin-bottom: 1.5rem; display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.analysis-tabs { display: flex; flex-wrap: wrap; gap: .35rem; } +.analysis-tabs button { min-height: 2.6rem; padding: .55rem .8rem; color: var(--subtext-0); background: transparent; border: 1px solid var(--surface-2); border-radius: .45rem; cursor: pointer; } +.analysis-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); border-color: var(--lavender); font-weight: 700; } +.analysis-toolbar label, .analysis-language, .analysis-picker { display: grid; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-sm); } +.analysis-toolbar select, .analysis-language select, .analysis-picker select { min-height: 2.6rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .45rem; } +.analysis-section-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.analysis-panel { min-width: 0; padding: clamp(1rem, 3vw, 1.75rem); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); overflow: hidden; } +.analysis-panel h3 { margin-bottom: .6rem; } +.analysis-wide { width: 100%; } +.analysis-facts { margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .75rem; } +.analysis-facts div { min-width: 0; padding: .75rem; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; } +.analysis-facts dt { color: var(--subtext-0); font-size: var(--font-size-xs); } +.analysis-facts dd { margin: .3rem 0 0; overflow-wrap: anywhere; font-size: 1.1rem; font-weight: 700; } +.analysis-empty, .analysis-note { color: var(--subtext-0); line-height: 1.55; } +.analysis-chart { min-width: 0; margin: 1rem 0 0; } +.analysis-chart svg { width: 100%; height: clamp(10rem, 25vw, 16rem); overflow: visible; } +.analysis-chart polyline { stroke: var(--lavender); stroke-width: 2; } +.analysis-chart circle { fill: var(--green); stroke: var(--base); stroke-width: .5; } +.analysis-chart figcaption { color: var(--subtext-0); font-size: var(--font-size-xs); } +.analysis-zones { display: grid; grid-template-columns: minmax(14rem, .8fr) minmax(16rem, 1.2fr); align-items: start; gap: 1.5rem; } +.analysis-zones ul { margin: 0; padding: 0; display: grid; gap: .5rem; list-style: none; } +.analysis-zones li { padding: .75rem; display: flex; justify-content: space-between; gap: 1rem; border-bottom: 1px solid var(--surface-1); } +.analysis-zones li span { color: var(--subtext-0); text-align: right; } +.analysis-link, .tile-context-link { width: max-content; max-width: 100%; color: var(--lavender); background: transparent; border: 0; padding: .35rem 0; text-decoration: underline; text-underline-offset: .2rem; cursor: pointer; } +.measurement-summary dd { display: grid; gap: .2rem; } +.measurement-summary small { color: var(--subtext-0); font-weight: 500; } +.program-progress p { margin: .25rem 0; color: var(--subtext-0); } + +@media (max-width: 48rem) { + .analysis-section-grid, .analysis-zones { grid-template-columns: 1fr; } + .analysis-page .page-heading { align-items: start; flex-direction: column; } + .analysis-toolbar { align-items: stretch; flex-direction: column; } + .analysis-toolbar label { width: 100%; } + .analysis-tabs button { flex: 1 1 8rem; } +} + .sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } .detail-actions, .confirmation-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .65rem; } .session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }