feat(web): add factual equipment merge and analysis aggregates

This commit is contained in:
fy59 2026-09-23 22:14:25 +02:00
parent f20e5c4d39
commit 7adeb59b06
10 changed files with 719 additions and 79 deletions

View file

@ -0,0 +1,27 @@
/* Trainlog loopback Web equipment projection and transactional merge. */
#ifndef TRAINLOG_WEB_EQUIPMENT_H
#define TRAINLOG_WEB_EQUIPMENT_H
#include <stddef.h>
#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

View file

@ -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',

View file

@ -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) ||

280
tui/src/web_equipment.c Normal file
View file

@ -0,0 +1,280 @@
#include "trainlog/web_equipment.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sqlite3.h>
#include <yyjson.h>
#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);
}

View file

@ -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;

View file

@ -14,22 +14,42 @@
#include <sqlite3.h>
#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;
}

View file

@ -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',

View file

@ -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' ||

View file

@ -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')
})
})

53
web/src/api/equipment.ts Normal file
View file

@ -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<string, unknown> =>
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<EquipmentSnapshot> {
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<EquipmentSnapshot> {
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())
}