From 7adeb59b066cf2777f3c3f347e4f693378fe1836 Mon Sep 17 00:00:00 2001 From: fy59 Date: Wed, 23 Sep 2026 22:14:25 +0200 Subject: [PATCH] feat(web): add factual equipment merge and analysis aggregates --- tui/include/trainlog/web_equipment.h | 27 +++ tui/meson.build | 6 + tui/src/web_analysis.c | 112 ++++++++++- tui/src/web_equipment.c | 280 +++++++++++++++++++++++++++ tui/src/web_server.c | 70 ++++++- tui/tests/test_custom_equipment.c | 205 ++++++++++++++------ web/src/api/analysis.test.ts | 8 +- web/src/api/analysis.ts | 15 +- web/src/api/equipment.test.ts | 22 +++ web/src/api/equipment.ts | 53 +++++ 10 files changed, 719 insertions(+), 79 deletions(-) create mode 100644 tui/include/trainlog/web_equipment.h create mode 100644 tui/src/web_equipment.c create mode 100644 web/src/api/equipment.test.ts create mode 100644 web/src/api/equipment.ts diff --git a/tui/include/trainlog/web_equipment.h b/tui/include/trainlog/web_equipment.h new file mode 100644 index 0000000..2e261e4 --- /dev/null +++ b/tui/include/trainlog/web_equipment.h @@ -0,0 +1,27 @@ +/* Trainlog loopback Web equipment projection and transactional merge. */ +#ifndef TRAINLOG_WEB_EQUIPMENT_H +#define TRAINLOG_WEB_EQUIPMENT_H + +#include + +#include "trainlog/database.h" +#include "trainlog/status.h" + +/* CONTRACT: returned JSON is heap-owned by the caller and must be freed. */ +TrainlogStatus +trainlog_web_equipment_list_json(TrainlogDatabase *database, char **output, size_t *output_size); + +/* WHY: duplicate physical-machine identities split historical context and MAX + * lookup. CONTRACT: only a custom duplicate may be absorbed; every relational + * reference is repointed in one IMMEDIATE transaction before the definition is + * deleted. INVARIANT: performed facts, timestamps, provenance and values are + * never rewritten, a matching causal marker makes exact replay idempotent, and + * embedded draft JSON causes a conflict rather than a partial or textual + * rewrite. */ +TrainlogStatus trainlog_web_equipment_merge_json(TrainlogDatabase *database, + const char *input, + size_t input_size, + char **output, + size_t *output_size); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 38a4b75..d295525 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -75,6 +75,7 @@ if web_assets_enabled '../web/src/dashboard/dashboardFormat.ts', '../web/src/dashboard/dashboardLayout.ts', '../web/src/presentation/DatePreferences.tsx', + '../web/src/presentation/LanguagePreferences.tsx', '../web/src/presentation/dateFormat.ts', '../web/src/routes/DashboardPage.tsx', '../web/src/routes/AnalysisPage.tsx', @@ -86,11 +87,15 @@ if web_assets_enabled '../web/src/routes/SessionsPage.tsx', '../web/src/api/sessions.ts', '../web/src/api/exercises.ts', + '../web/src/api/equipment.ts', + '../web/src/api/equipment.test.ts', '../web/src/api/exercises.test.ts', '../web/src/api/programs.ts', '../web/src/routes/ProgramsTab.tsx', '../web/src/routes/ProgramsCalendarPage.tsx', '../web/src/routes/ExercisesPage.tsx', + '../web/src/routes/EquipmentPage.tsx', + '../web/src/routes/SettingsPage.tsx', '../web/src/routes/ExercisesPage.test.tsx', '../web/src/theme/app.css', '../web/src/theme/tokens.css', @@ -226,6 +231,7 @@ trainlog_core_sources = files( 'src/web_programs.c', 'src/web_sleep.c', 'src/web_exercises.c', + 'src/web_equipment.c', 'src/duration.c', 'src/id.c', 'src/timeutil.c', diff --git a/tui/src/web_analysis.c b/tui/src/web_analysis.c index ac0f814..5d78e3f 100644 --- a/tui/src/web_analysis.c +++ b/tui/src/web_analysis.c @@ -91,7 +91,12 @@ static bool add_overview(TrainlogDatabase *database, "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"; + "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; @@ -109,6 +114,8 @@ static bool add_overview(TrainlogDatabase *database, ? 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; @@ -120,7 +127,8 @@ static bool add_activity(TrainlogDatabase *database, 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 " + "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 " @@ -154,6 +162,8 @@ static bool add_activity(TrainlogDatabase *database, !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; @@ -164,6 +174,100 @@ static bool add_activity(TrainlogDatabase *database, 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, @@ -553,7 +657,8 @@ add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val "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)," - "(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 " + "(SELECT ps2.planned_for 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) " @@ -658,6 +763,7 @@ TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database, : "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) || diff --git a/tui/src/web_equipment.c b/tui/src/web_equipment.c new file mode 100644 index 0000000..f6678c1 --- /dev/null +++ b/tui/src/web_equipment.c @@ -0,0 +1,280 @@ +#include "trainlog/web_equipment.h" + +#include +#include +#include +#include + +#include +#include + +#include "database_internal.h" +#include "trainlog/equipment_catalog.h" + +static bool add_count(sqlite3 *connection, + yyjson_mut_doc *document, + yyjson_mut_val *item, + const char *key, + const char *sql, + const char *equipment_id) { + sqlite3_stmt *statement = NULL; + bool ok = sqlite3_prepare_v2(connection, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW && + yyjson_mut_obj_add_sint(document, item, key, sqlite3_column_int64(statement, 0)) && + sqlite3_step(statement) == SQLITE_DONE; + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK) { + ok = false; + } + return ok; +} + +static bool add_item(TrainlogDatabase *database, + yyjson_mut_doc *document, + yyjson_mut_val *items, + const TrainlogResolvedEquipment *equipment) { + static const char EXERCISES[] = + "SELECT COUNT(DISTINCT exercise_row_id) FROM session_exercises WHERE equipment_id=?1"; + static const char HISTORY[] = "SELECT COUNT(*) FROM session_exercises WHERE equipment_id=?1"; + static const char PREPARATIONS[] = + "SELECT (SELECT COUNT(*) FROM session_preparation_entries WHERE equipment_id=?1)+" + "(SELECT COUNT(*) FROM ai_session_draft_entries WHERE equipment_id=?1)"; + static const char PROGRAMS[] = + "SELECT COUNT(*) FROM program_session_entries WHERE equipment_id=?1"; + const char *origin = equipment->origin == TRAINLOG_EQUIPMENT_SUPPLIED ? "supplied" + : equipment->origin == TRAINLOG_EQUIPMENT_CUSTOM ? "custom" + : "unknown"; + yyjson_mut_val *item = yyjson_mut_obj(document); + return item != NULL && + yyjson_mut_obj_add_strcpy(document, item, "equipment_id", equipment->equipment_id) && + yyjson_mut_obj_add_strcpy(document, item, "display_name", equipment->display_name) && + yyjson_mut_obj_add_strcpy(document, item, "equipment_type", equipment->equipment_type) && + yyjson_mut_obj_add_strcpy(document, item, "load_semantics", equipment->load_semantics) && + yyjson_mut_obj_add_strcpy(document, item, "origin", origin) && + add_count(database->connection, + document, + item, + "exercise_count", + EXERCISES, + equipment->equipment_id) && + add_count(database->connection, + document, + item, + "historical_occurrences", + HISTORY, + equipment->equipment_id) && + add_count(database->connection, + document, + item, + "preparation_references", + PREPARATIONS, + equipment->equipment_id) && + add_count(database->connection, + document, + item, + "program_references", + PROGRAMS, + equipment->equipment_id) && + yyjson_mut_arr_add_val(items, item); +} + +TrainlogStatus +trainlog_web_equipment_list_json(TrainlogDatabase *database, char **output, size_t *output_size) { + TrainlogCustomEquipment custom[TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX]; + size_t custom_count = 0U; + size_t index; + yyjson_mut_doc *document; + yyjson_mut_val *root; + yyjson_mut_val *items; + if (database == NULL || output == NULL || output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + *output = NULL; + *output_size = 0U; + if (trainlog_database_list_custom_equipment( + database, custom, TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &custom_count) != + TRAINLOG_STATUS_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + document = yyjson_mut_doc_new(NULL); + root = document == NULL ? NULL : yyjson_mut_obj(document); + items = document == NULL ? NULL : yyjson_mut_arr(document); + if (root == NULL || items == NULL) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + yyjson_mut_doc_set_root(document, root); + if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + for (index = 0U; index < trainlog_equipment_catalog_count(); ++index) { + const TrainlogEquipment *source = trainlog_equipment_catalog_at(index); + TrainlogResolvedEquipment resolved = {0}; + if (source == NULL) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + (void)snprintf( + resolved.equipment_id, sizeof(resolved.equipment_id), "%s", source->equipment_id); + (void)snprintf( + resolved.display_name, sizeof(resolved.display_name), "%s", source->display_name); + (void)snprintf( + resolved.equipment_type, sizeof(resolved.equipment_type), "%s", source->equipment_type); + (void)snprintf( + resolved.load_semantics, sizeof(resolved.load_semantics), "%s", source->load_semantics); + resolved.origin = TRAINLOG_EQUIPMENT_SUPPLIED; + if (!add_item(database, document, items, &resolved)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + for (index = 0U; index < custom_count; ++index) { + TrainlogResolvedEquipment resolved; + if (trainlog_database_resolve_equipment(database, custom[index].equipment_id, &resolved) != + TRAINLOG_STATUS_OK || + !add_item(database, document, items, &resolved)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + if (!yyjson_mut_obj_add_val(document, root, "items", items)) { + yyjson_mut_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + *output = yyjson_mut_write(document, 0U, output_size); + yyjson_mut_doc_free(document); + return *output == NULL ? TRAINLOG_STATUS_DATABASE_ERROR : TRAINLOG_STATUS_OK; +} + +static bool +execute_bound(sqlite3 *connection, const char *sql, const char *canonical, const char *duplicate) { + sqlite3_stmt *statement = NULL; + bool ok = sqlite3_prepare_v2(connection, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_bind_text(statement, 1, canonical, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_bind_text(statement, 2, duplicate, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_DONE; + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK) { + ok = false; + } + return ok; +} + +static bool embedded_draft_references(sqlite3 *connection, const char *equipment_id) { + static const char SQL[] = + "SELECT (SELECT COUNT(*) FROM execution_drafts WHERE " + "instr(payload_json,json_quote(?1))>0)+(SELECT COUNT(*) FROM " + "execution_draft_revisions WHERE instr(payload_json,json_quote(?1))>0)"; + sqlite3_stmt *statement = NULL; + bool referenced = true; + if (sqlite3_prepare_v2(connection, SQL, -1, &statement, NULL) == SQLITE_OK && + sqlite3_bind_text(statement, 1, equipment_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + referenced = sqlite3_column_int64(statement, 0) > 0; + } + (void)sqlite3_finalize(statement); + return referenced; +} + +static bool +merge_already_applied(sqlite3 *connection, const char *canonical, const char *duplicate) { + static const char SQL[] = + "SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND " + "target_id=?2 AND deleted=1 AND current_revision_id='equipment_merge_v1:'||?1"; + sqlite3_stmt *statement = NULL; + bool applied = sqlite3_prepare_v2(connection, SQL, -1, &statement, NULL) == SQLITE_OK && + sqlite3_bind_text(statement, 1, canonical, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_bind_text(statement, 2, duplicate, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW; + (void)sqlite3_finalize(statement); + return applied; +} + +TrainlogStatus trainlog_web_equipment_merge_json(TrainlogDatabase *database, + const char *input, + size_t input_size, + char **output, + size_t *output_size) { + static const char *const UPDATES[] = { + "UPDATE session_exercises SET equipment_id=?1 WHERE equipment_id=?2", + "UPDATE ai_session_draft_entries SET equipment_id=?1 WHERE equipment_id=?2", + "UPDATE session_preparation_entries SET equipment_id=?1 WHERE equipment_id=?2", + "UPDATE program_session_entries SET equipment_id=?1 WHERE equipment_id=?2", + "UPDATE exercises SET legacy_equipment_id=?1 WHERE legacy_equipment_id=?2", + }; + yyjson_doc *document; + yyjson_val *root; + yyjson_val *canonical_value; + yyjson_val *duplicate_value; + const char *canonical; + const char *duplicate; + TrainlogResolvedEquipment resolved; + size_t index; + bool ok = true; + if (database == NULL || input == NULL || input_size == 0U || output == NULL || + output_size == NULL) { + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + document = yyjson_read_opts((char *)input, input_size, YYJSON_READ_NOFLAG, NULL, NULL); + root = document == NULL ? NULL : yyjson_doc_get_root(document); + canonical_value = root == NULL ? NULL : yyjson_obj_get(root, "canonical_id"); + duplicate_value = root == NULL ? NULL : yyjson_obj_get(root, "duplicate_id"); + canonical = yyjson_is_str(canonical_value) ? yyjson_get_str(canonical_value) : NULL; + duplicate = yyjson_is_str(duplicate_value) ? yyjson_get_str(duplicate_value) : NULL; + if (canonical == NULL || duplicate == NULL || canonical[0] == '\0' || duplicate[0] == '\0' || + strcmp(canonical, duplicate) == 0 || strlen(canonical) > TRAINLOG_ID_MAX || + strlen(duplicate) > TRAINLOG_ID_MAX || + trainlog_database_resolve_equipment(database, canonical, &resolved) != TRAINLOG_STATUS_OK || + resolved.origin == TRAINLOG_EQUIPMENT_UNKNOWN) { + yyjson_doc_free(document); + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (merge_already_applied(database->connection, canonical, duplicate)) { + yyjson_doc_free(document); + return trainlog_web_equipment_list_json(database, output, output_size); + } + if (trainlog_database_resolve_equipment(database, duplicate, &resolved) != TRAINLOG_STATUS_OK || + resolved.origin != TRAINLOG_EQUIPMENT_CUSTOM) { + yyjson_doc_free(document); + return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (embedded_draft_references(database->connection, duplicate)) { + yyjson_doc_free(document); + return TRAINLOG_STATUS_CONFLICT; + } + /* CONTRACT: embedded JSON revisions are immutable evidence. Refuse a merge + * if one mentions the duplicate instead of corrupting or partially moving it. */ + if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + yyjson_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + for (index = 0U; index < sizeof(UPDATES) / sizeof(UPDATES[0]); ++index) { + if (!execute_bound(database->connection, UPDATES[index], canonical, duplicate)) { + ok = false; + break; + } + } + if (ok) { + ok = execute_bound(database->connection, + "DELETE FROM custom_equipment WHERE equipment_id=?2", + canonical, + duplicate); + } + if (ok) { + ok = execute_bound(database->connection, + "INSERT INTO sync_causal_state(target_kind,target_id," + "current_revision_id,deleted,operation_id) VALUES(" + "'custom_equipment',?2,'equipment_merge_v1:'||?1,1,NULL) ON CONFLICT(" + "target_kind,target_id) DO UPDATE SET current_revision_id=" + "excluded.current_revision_id,deleted=1,operation_id=NULL", + canonical, + duplicate); + } + if (!ok || sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { + (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL); + yyjson_doc_free(document); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + yyjson_doc_free(document); + return trainlog_web_equipment_list_json(database, output, output_size); +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 64100ef..a515e20 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -24,6 +24,7 @@ #include "trainlog/web_analysis.h" #include "trainlog/web_heart_rate.h" #include "trainlog/web_exercises.h" +#include "trainlog/web_equipment.h" #include "trainlog/web_prepared_items.h" #include "trainlog/web_programs.h" #include "trainlog/web_session_deletions.h" @@ -554,7 +555,8 @@ static enum MHD_Result queue_asset(struct MHD_Connection *connection, } static bool is_ui_route(const char *url) { - static const char *const routes[] = {"/", "/analyse", "/programmes", "/seances", "/exercices"}; + static const char *const routes[] = { + "/", "/analyse", "/programmes", "/seances", "/exercices", "/equipements", "/parametres"}; size_t index; for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index) { if (strcmp(url, routes[index]) == 0) { @@ -1404,8 +1406,7 @@ static enum MHD_Result handle_request(void *closure, } (void)MHD_get_connection_values( connection, MHD_GET_ARGUMENT_KIND, validate_heart_rate_argument, &arguments); - context_id = - MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "context_id"); + context_id = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "context_id"); if (!arguments.valid || arguments.context_count != 1U || !valid_heart_rate_context_id(context_id)) { return queue_json(connection, @@ -1420,11 +1421,8 @@ static enum MHD_Result handle_request(void *closure, "{\"error\":\"heart_rate_timeline_unavailable\"}\n", NULL); } - status = trainlog_web_heart_rate_timeline_json(context->database, - context_id, - json, - TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, - &json_size); + status = trainlog_web_heart_rate_timeline_json( + context->database, context_id, json, TRAINLOG_WEB_HEART_RATE_JSON_CAPACITY, &json_size); if (status != TRAINLOG_STATUS_OK || json_size == 0U) { free(json); return queue_json(connection, @@ -1645,6 +1643,62 @@ static enum MHD_Result handle_request(void *closure, } return queue_json(connection, MHD_HTTP_OK, json, NULL); } + if (strcmp(url, "/api/v1/equipment") == 0) { + char *json = NULL; + size_t json_size = 0U; + TrainlogStatus status; + enum MHD_Result queued; + if (!is_get) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "GET"); + } + status = trainlog_web_equipment_list_json(context->database, &json, &json_size); + if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) { + free(json); + return queue_json(connection, + MHD_HTTP_INTERNAL_SERVER_ERROR, + "{\"error\":\"equipment_unavailable\"}\n", + NULL); + } + queued = queue_json(connection, MHD_HTTP_OK, json, NULL); + free(json); + return queued; + } + if (strcmp(url, "/api/v1/equipment/merge") == 0) { + char *json = NULL; + size_t json_size = 0U; + TrainlogStatus status; + enum MHD_Result queued; + if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) { + return queue_json(connection, + MHD_HTTP_METHOD_NOT_ALLOWED, + "{\"error\":\"method_not_allowed\"}\n", + "POST"); + } + if (!program_mutation_allowed(context, connection)) { + return queue_json( + connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL); + } + status = trainlog_web_equipment_merge_json( + context->database, state->body, state->body_size, &json, &json_size); + if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) { + free(json); + return queue_json(connection, + MHD_HTTP_UNPROCESSABLE_CONTENT, + "{\"error\":\"invalid_equipment_merge\"}\n", + NULL); + } + if (status != TRAINLOG_STATUS_OK || json == NULL || json_size == 0U) { + free(json); + return queue_json( + connection, MHD_HTTP_CONFLICT, "{\"error\":\"equipment_merge_failed\"}\n", NULL); + } + queued = queue_json(connection, MHD_HTTP_OK, json, NULL); + free(json); + return queued; + } if (strcmp(url, "/api/v1/prepared-items") == 0) { char json[TRAINLOG_WEB_PREPARED_ITEMS_JSON_CAPACITY]; size_t json_size = 0U; diff --git a/tui/tests/test_custom_equipment.c b/tui/tests/test_custom_equipment.c index b77fbeb..31e9fcd 100644 --- a/tui/tests/test_custom_equipment.c +++ b/tui/tests/test_custom_equipment.c @@ -14,22 +14,42 @@ #include #include "trainlog/database.h" +#include "trainlog/web_equipment.h" -#define CHECK(condition) do { if (!(condition)) { \ - (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ - return false; } } while (0) +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return false; \ + } \ + } while (0) static const char *const V7_SQL = - "CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0);" - "CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT NOT NULL,ended_at TEXT,session_type TEXT NOT NULL DEFAULT 'training',notes TEXT);" - "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));" - "CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds INTEGER,weight_kg REAL);" - "CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh REAL,distance_km REAL);" - "CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER REFERENCES sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);" + "CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT NOT NULL UNIQUE,name TEXT NOT " + "NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,recording_mode TEXT NOT " + "NULL DEFAULT 'sets',data_fields INTEGER NOT NULL DEFAULT 0);" + "CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE,started_at TEXT " + "NOT NULL,ended_at TEXT,session_type TEXT NOT NULL DEFAULT 'training',notes TEXT);" + "CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,entry_id TEXT NOT NULL " + "UNIQUE,session_row_id INTEGER NOT NULL REFERENCES sessions(id),exercise_row_id INTEGER NOT " + "NULL REFERENCES exercises(id),recording_mode TEXT NOT NULL DEFAULT 'sets',data_fields INTEGER " + "NOT NULL DEFAULT 0,position INTEGER NOT NULL,load_mode TEXT NOT NULL,rest_seconds INTEGER NOT " + "NULL,target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg " + "REAL,equipment_id TEXT,notes TEXT,UNIQUE(session_row_id,position));" + "CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT NULL " + "REFERENCES session_exercises(id),position INTEGER NOT NULL,reps INTEGER,duration_seconds " + "INTEGER,weight_kg REAL);" + "CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER NOT " + "NULL UNIQUE REFERENCES session_exercises(id),duration_seconds INTEGER NOT NULL,speed_kmh " + "REAL,distance_km REAL);" + "CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT NOT NULL " + "UNIQUE,observed_at TEXT NOT NULL,session_row_id INTEGER REFERENCES " + "sessions(id),body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm " + "REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm " + "REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL,notes TEXT);" "PRAGMA user_version=7;"; -static bool test_custom_equipment_round_trip(void) -{ +static bool test_custom_equipment_round_trip(void) { char path[] = "/tmp/trainlog-custom-equipment-XXXXXX"; TrainlogDatabase *database = NULL; TrainlogCustomEquipment custom; @@ -43,6 +63,8 @@ static bool test_custom_equipment_round_trip(void) TrainlogPersistedExerciseDetail detail[2]; sqlite3 *raw = NULL; size_t count = 0U; + char *equipment_json = NULL; + size_t equipment_json_size = 0U; int version = 0; int fd = mkstemp(path); CHECK(fd >= 0); @@ -55,8 +77,10 @@ static bool test_custom_equipment_round_trip(void) CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); (void)memset(&custom, 0, sizeof(custom)); - (void)snprintf(custom.equipment_id, sizeof(custom.equipment_id), - "%s", "eq_123e4567-e89b-42d3-a456-426614174000"); + (void)snprintf(custom.equipment_id, + sizeof(custom.equipment_id), + "%s", + "eq_123e4567-e89b-42d3-a456-426614174000"); (void)snprintf(custom.display_name, sizeof(custom.display_name), "%s", "Poulie maison"); (void)snprintf(custom.label_name, sizeof(custom.label_name), "%s", "POULIE A"); (void)snprintf(custom.equipment_type, sizeof(custom.equipment_type), "%s", "cable_machine"); @@ -66,11 +90,15 @@ static bool test_custom_equipment_round_trip(void) database = NULL; CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_list_custom_equipment(database, listed, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment(database, listed, 2U, &count) == + TRAINLOG_STATUS_OK); CHECK(count == 1U); CHECK(strcmp(listed[0].equipment_id, custom.equipment_id) == 0); - CHECK(trainlog_database_insert_exercise(database, "ex_custom_equipment", - "Tirage custom", "tirage custom", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(database, + "ex_custom_equipment", + "Tirage custom", + "tirage custom", + TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); (void)memset(&set, 0, sizeof(set)); set.reps = 8; set.has_weight = true; @@ -89,7 +117,8 @@ static bool test_custom_equipment_round_trip(void) exercise.set_count = 1U; (void)memset(&session, 0, sizeof(session)); (void)snprintf(session.session_id, sizeof(session.session_id), "%s", "se_custom_equipment"); - (void)snprintf(session.started_at, sizeof(session.started_at), "%s", "2026-09-08T10:00:00+02:00"); + (void)snprintf( + session.started_at, sizeof(session.started_at), "%s", "2026-09-08T10:00:00+02:00"); (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-08T10:30:00+02:00"); session.exercises = &exercise; session.exercise_count = 1U; @@ -97,28 +126,59 @@ static bool test_custom_equipment_round_trip(void) trainlog_database_close(database); database = NULL; CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_database_get_session_details(database, session.session_id, - &summary, detail, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_get_session_details( + database, session.session_id, &summary, detail, 2U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); CHECK(strcmp(detail[0].entry_id, exercise.entry_id) == 0); CHECK(strcmp(detail[0].equipment_id, custom.equipment_id) == 0); trainlog_database_free_session_details(detail, count); - CHECK(trainlog_database_list_exercise_equipment(database, exercise.exercise_id, - used, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_exercise_equipment( + database, exercise.exercise_id, used, 2U, &count) == TRAINLOG_STATUS_OK); CHECK(count == 1U); CHECK(used[0].origin == TRAINLOG_EQUIPMENT_CUSTOM); CHECK(strcmp(used[0].display_name, custom.display_name) == 0); - CHECK(trainlog_database_resolve_equipment(database, "retired_machine", &resolved) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_resolve_equipment(database, "retired_machine", &resolved) == + TRAINLOG_STATUS_OK); CHECK(resolved.origin == TRAINLOG_EQUIPMENT_UNKNOWN); CHECK(strstr(resolved.display_name, "retired_machine") != NULL); + CHECK(trainlog_web_equipment_merge_json( + database, + "{\"canonical_id\":\"functional_trainer\",\"duplicate_id\":\"eq_123e4567-e89b-42d3-" + "a456-426614174000\"}", + strlen("{\"canonical_id\":\"functional_trainer\",\"duplicate_id\":\"eq_123e4567-e89b-" + "42d3-a456-426614174000\"}"), + &equipment_json, + &equipment_json_size) == TRAINLOG_STATUS_OK); + CHECK(equipment_json != NULL && equipment_json_size > 0U); + free(equipment_json); + equipment_json = NULL; + CHECK(trainlog_database_get_session_details( + database, session.session_id, &summary, detail, 2U, &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U); + CHECK(strcmp(detail[0].equipment_id, "functional_trainer") == 0); + CHECK(trainlog_database_resolve_equipment(database, custom.equipment_id, &resolved) == + TRAINLOG_STATUS_OK); + CHECK(resolved.origin == TRAINLOG_EQUIPMENT_UNKNOWN); + CHECK(trainlog_web_equipment_merge_json( + database, + "{\"canonical_id\":\"functional_trainer\",\"duplicate_id\":\"eq_123e4567-e89b-42d3-" + "a456-426614174000\"}", + strlen("{\"canonical_id\":\"functional_trainer\",\"duplicate_id\":\"eq_123e4567-e89b-" + "42d3-a456-426614174000\"}"), + &equipment_json, + &equipment_json_size) == TRAINLOG_STATUS_OK); + CHECK(equipment_json != NULL && equipment_json_size > 0U); + free(equipment_json); + equipment_json = NULL; + trainlog_database_free_session_details(detail, count); trainlog_database_close(database); CHECK(unlink(path) == 0); return true; } -static bool insert_paged_equipment( - TrainlogDatabase *database, const char *equipment_id, const char *display_name) -{ +static bool insert_paged_equipment(TrainlogDatabase *database, + const char *equipment_id, + const char *display_name) { TrainlogCustomEquipment equipment; (void)memset(&equipment, 0, sizeof(equipment)); (void)snprintf(equipment.equipment_id, sizeof(equipment.equipment_id), "%s", equipment_id); @@ -129,18 +189,18 @@ static bool insert_paged_equipment( return trainlog_database_create_custom_equipment(database, &equipment) == TRAINLOG_STATUS_OK; } -static bool test_custom_equipment_page_corruption(void) -{ +static bool test_custom_equipment_page_corruption(void) { char path[] = "/tmp/trainlog-custom-equipment-page-XXXXXX"; TrainlogDatabase *database = NULL; TrainlogCustomEquipment page[2]; sqlite3 *raw = NULL; sqlite3_stmt *statement = NULL; - const unsigned char blob[] = { 'x', 'y' }; - const char embedded_nul[] = { 'x', '\0', 'y' }; + const unsigned char blob[] = {'x', 'y'}; + const char embedded_nul[] = {'x', '\0', 'y'}; char oversized[TRAINLOG_NAME_MAX + 2U]; - const void *invalid_values[] = { oversized, blob, embedded_nul }; - const int invalid_lengths[] = { (int)sizeof(oversized), (int)sizeof(blob), (int)sizeof(embedded_nul) }; + const void *invalid_values[] = {oversized, blob, embedded_nul}; + const int invalid_lengths[] = { + (int)sizeof(oversized), (int)sizeof(blob), (int)sizeof(embedded_nul)}; size_t count; bool more; size_t index; @@ -152,31 +212,45 @@ static bool test_custom_equipment_page_corruption(void) CHECK(insert_paged_equipment(database, "eq_page_valid", "Alpha")); CHECK(insert_paged_equipment(database, "eq_page_invalid", "Bravo")); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_prepare_v2(raw, "UPDATE custom_equipment SET label_name=?1 WHERE equipment_id='eq_page_invalid';", - -1, &statement, NULL) == SQLITE_OK); + CHECK(sqlite3_prepare_v2( + raw, + "UPDATE custom_equipment SET label_name=?1 WHERE equipment_id='eq_page_invalid';", + -1, + &statement, + NULL) == SQLITE_OK); (void)memset(oversized, 'x', sizeof(oversized)); /* label_name is NOT NULL, so NULL cannot reach the reader's defensive branch. */ for (index = 0U; index < 3U; ++index) { if (index == 1U) { - CHECK(sqlite3_bind_blob(statement, 1, invalid_values[index], invalid_lengths[index], SQLITE_TRANSIENT) == SQLITE_OK); + CHECK(sqlite3_bind_blob(statement, + 1, + invalid_values[index], + invalid_lengths[index], + SQLITE_TRANSIENT) == SQLITE_OK); } else { - CHECK(sqlite3_bind_text(statement, 1, (const char *)invalid_values[index], - invalid_lengths[index], SQLITE_TRANSIENT) == SQLITE_OK); + CHECK(sqlite3_bind_text(statement, + 1, + (const char *)invalid_values[index], + invalid_lengths[index], + SQLITE_TRANSIENT) == SQLITE_OK); } CHECK(sqlite3_step(statement) == SQLITE_DONE); CHECK(sqlite3_reset(statement) == SQLITE_OK); count = 9U; more = true; - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 1U, &count, &more) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 1U, &count, &more) == + TRAINLOG_STATUS_DATABASE_ERROR); CHECK(count == 0U && !more); count = 9U; more = true; - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == TRAINLOG_STATUS_DATABASE_ERROR); + CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == + TRAINLOG_STATUS_DATABASE_ERROR); CHECK(count == 0U && !more); CHECK(sqlite3_bind_text(statement, 1, "Bravo", -1, SQLITE_STATIC) == SQLITE_OK); CHECK(sqlite3_step(statement) == SQLITE_DONE); CHECK(sqlite3_reset(statement) == SQLITE_OK); - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == + TRAINLOG_STATUS_OK); CHECK(count == 2U && !more && strcmp(page[0].equipment_id, "eq_page_valid") == 0); } CHECK(sqlite3_finalize(statement) == SQLITE_OK); @@ -186,8 +260,7 @@ static bool test_custom_equipment_page_corruption(void) return true; } -static bool test_custom_equipment_page_reader(void) -{ +static bool test_custom_equipment_page_reader(void) { TrainlogDatabase *database = NULL; TrainlogCustomEquipment page[TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX]; char equipment_id[32]; @@ -205,8 +278,9 @@ static bool test_custom_equipment_page_reader(void) CHECK(insert_paged_equipment(database, equipment_id, display_name)); } - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, - TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment_page( + database, 0U, page, TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == + TRAINLOG_STATUS_OK); CHECK(count == TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX); CHECK(more); CHECK(strcmp(page[0].equipment_id, "eq_tie_a") == 0); @@ -214,38 +288,40 @@ static bool test_custom_equipment_page_reader(void) CHECK(strcmp(page[2].equipment_id, "eq_page_000") == 0); CHECK(strcmp(page[127].equipment_id, "eq_page_125") == 0); - CHECK(trainlog_database_list_custom_equipment_page(database, 128U, page, - TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment_page( + database, 128U, page, TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == + TRAINLOG_STATUS_OK); CHECK(count == 4U); CHECK(!more); CHECK(strcmp(page[0].equipment_id, "eq_page_126") == 0); CHECK(strcmp(page[3].equipment_id, "eq_page_129") == 0); - CHECK(trainlog_database_list_custom_equipment_page(database, 131U, page, 1U, - &count, &more) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment_page(database, 131U, page, 1U, &count, &more) == + TRAINLOG_STATUS_OK); CHECK(count == 1U); CHECK(!more); CHECK(strcmp(page[0].equipment_id, "eq_page_129") == 0); - CHECK(trainlog_database_list_custom_equipment_page(database, 132U, page, 1U, - &count, &more) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_list_custom_equipment_page(database, 132U, page, 1U, &count, &more) == + TRAINLOG_STATUS_OK); CHECK(count == 0U); CHECK(!more); count = 7U; more = true; - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 0U, - &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 0U, &count, &more) == + TRAINLOG_STATUS_INVALID_ARGUMENT); CHECK(count == 7U); CHECK(more); - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, NULL, 1U, - &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT); - CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, - TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX + 1U, &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_list_custom_equipment_page(database, 0U, NULL, 1U, &count, &more) == + TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_list_custom_equipment_page( + database, 0U, page, TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX + 1U, &count, &more) == + TRAINLOG_STATUS_INVALID_ARGUMENT); if ((uintmax_t)SIZE_MAX > (uintmax_t)INT64_MAX) { count = 7U; more = true; - CHECK(trainlog_database_list_custom_equipment_page(database, SIZE_MAX, page, 1U, - &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT); + CHECK(trainlog_database_list_custom_equipment_page( + database, SIZE_MAX, page, 1U, &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT); CHECK(count == 0U); CHECK(!more); } @@ -253,11 +329,16 @@ static bool test_custom_equipment_page_reader(void) return true; } -int main(void) -{ - if (!test_custom_equipment_round_trip()) return 1; - if (!test_custom_equipment_page_corruption()) return 1; - if (!test_custom_equipment_page_reader()) return 1; +int main(void) { + if (!test_custom_equipment_round_trip()) { + return 1; + } + if (!test_custom_equipment_page_corruption()) { + return 1; + } + if (!test_custom_equipment_page_reader()) { + return 1; + } (void)printf("PASS custom_equipment\n"); return 0; } diff --git a/web/src/api/analysis.test.ts b/web/src/api/analysis.test.ts index 293f39d..eecef3d 100644 --- a/web/src/api/analysis.test.ts +++ b/web/src/api/analysis.test.ts @@ -4,8 +4,12 @@ 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 }], + overview: { sessions: 2, sets: 4, duration_seconds: null, distinct_exercises: 1 }, + activity: [{ date: '2026-09-20', sessions: 2, sets: 4, exercises: 1 }], + exercise_groups: [{ zone_id: 'thighs', label: 'Cuisses', exercises: [{ + exercise_id: 'ex_10000000-0000-4000-8000-000000000001', name: 'Presse', + occurrences: 2, sessions: 2, sets: 4, measured_seconds: 0, measured_occurrences: 0, + }] }], 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', diff --git a/web/src/api/analysis.ts b/web/src/api/analysis.ts index 5a446bb..f42886d 100644 --- a/web/src/api/analysis.ts +++ b/web/src/api/analysis.ts @@ -9,7 +9,9 @@ export const MEASUREMENT_METRICS: readonly MeasurementMetric[] = [ 'left_forearm', 'right_forearm', 'left_thigh', 'right_thigh', 'left_calf', 'right_calf', ] -export interface AnalysisActivityDay { date: string; sessions: number; sets: number } +export interface AnalysisActivityDay { date: string; sessions: number; sets: number; exercises: number } +export interface AnalysisExerciseGroupItem { exercise_id: string; name: string; occurrences: number; sessions: number; sets: number; measured_seconds: number; measured_occurrences: number } +export interface AnalysisExerciseGroup { zone_id: string; label: string; exercises: AnalysisExerciseGroupItem[] } 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' @@ -39,8 +41,9 @@ export interface ActiveProgram { export interface AnalysisSnapshot { api_version: 1 period: AnalysisPeriod - overview: { sessions: number; sets: number; duration_seconds: number | null } + overview: { sessions: number; sets: number; duration_seconds: number | null; distinct_exercises: number } activity: AnalysisActivityDay[] + exercise_groups: AnalysisExerciseGroup[] exercises: AnalysisExerciseOption[] exercise: AnalysisExercise | null body_zones: AnalysisZone[] @@ -68,8 +71,12 @@ 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 (!count(value.overview.sessions) || !count(value.overview.sets) || !nullableFinite(value.overview.duration_seconds) || (value.overview.distinct_exercises !== undefined && !count(value.overview.distinct_exercises))) throw new TypeError('analysis_overview_invalid') + value.overview.distinct_exercises = Number(value.overview.distinct_exercises ?? 0) + if (value.activity.length > 90 || !value.activity.every((item) => record(item) && typeof item.date === 'string' && count(item.sessions) && count(item.sets) && (item.exercises === undefined || count(item.exercises)))) throw new TypeError('analysis_activity_invalid') + value.activity = value.activity.map((item) => ({ ...item, exercises: Number(item.exercises ?? 0) })) + if (value.exercise_groups === undefined) value.exercise_groups = [] + if (!Array.isArray(value.exercise_groups) || !value.exercise_groups.every((group) => record(group) && typeof group.zone_id === 'string' && typeof group.label === 'string' && Array.isArray(group.exercises) && group.exercises.every((item) => record(item) && typeof item.exercise_id === 'string' && typeof item.name === 'string' && count(item.occurrences) && count(item.sessions) && count(item.sets) && count(item.measured_seconds) && count(item.measured_occurrences)))) throw new TypeError('analysis_exercise_groups_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' || diff --git a/web/src/api/equipment.test.ts b/web/src/api/equipment.test.ts new file mode 100644 index 0000000..39bf3fc --- /dev/null +++ b/web/src/api/equipment.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseEquipment } from './equipment' + +describe('equipment API', () => { + it('accepts bounded factual merge consequences', () => { + const snapshot = parseEquipment({ api_version: 1, items: [{ + equipment_id: 'functional_trainer', display_name: 'Poulie', + equipment_type: 'cable_machine', load_semantics: 'external', origin: 'supplied', + exercise_count: 2, historical_occurrences: 4, preparation_references: 1, + program_references: 1, + }] }) + expect(snapshot.items[0].historical_occurrences).toBe(4) + }) + + it('rejects negative or untyped reference counts', () => { + expect(() => parseEquipment({ api_version: 1, items: [{ + equipment_id: 'x', display_name: 'X', equipment_type: 'machine', + load_semantics: 'external', origin: 'custom', exercise_count: -1, + historical_occurrences: 0, preparation_references: 0, program_references: 0, + }] })).toThrow('equipment_invalid') + }) +}) diff --git a/web/src/api/equipment.ts b/web/src/api/equipment.ts new file mode 100644 index 0000000..8555396 --- /dev/null +++ b/web/src/api/equipment.ts @@ -0,0 +1,53 @@ +import { mutationCsrfToken } from './sync' + +export interface EquipmentItem { + equipment_id: string + display_name: string + equipment_type: string + load_semantics: string + origin: 'supplied' | 'custom' | 'unknown' + exercise_count: number + historical_occurrences: number + preparation_references: number + program_references: number +} + +export interface EquipmentSnapshot { api_version: 1; items: EquipmentItem[] } + +const object = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +export function parseEquipment(value: unknown): EquipmentSnapshot { + if (!object(value) || value.api_version !== 1 || !Array.isArray(value.items) || + !value.items.every((item) => object(item) && typeof item.equipment_id === 'string' && + typeof item.display_name === 'string' && typeof item.equipment_type === 'string' && + typeof item.load_semantics === 'string' && ['supplied', 'custom', 'unknown'].includes(String(item.origin)) && + ['exercise_count', 'historical_occurrences', 'preparation_references', 'program_references'] + .every((key) => Number.isSafeInteger(item[key]) && Number(item[key]) >= 0))) { + throw new TypeError('equipment_invalid') + } + return value as unknown as EquipmentSnapshot +} + +export async function fetchEquipment(signal?: AbortSignal): Promise { + const response = await fetch('/api/v1/equipment', { headers: { Accept: 'application/json' }, signal }) + if (!response.ok) throw new Error(`equipment HTTP ${response.status}`) + return parseEquipment(await response.json()) +} + +export async function mergeEquipment(canonicalId: string, duplicateId: string): Promise { + const response = await fetch('/api/v1/equipment/merge', { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'X-Trainlog-CSRF-Token': await mutationCsrfToken(), + 'X-Trainlog-Request-ID': crypto.randomUUID(), + }, + body: JSON.stringify({ canonical_id: canonicalId, duplicate_id: duplicateId }), + }) + if (!response.ok) { + const body: unknown = await response.json().catch(() => null) + throw new Error(object(body) && typeof body.error === 'string' ? body.error : `equipment HTTP ${response.status}`) + } + return parseEquipment(await response.json()) +}