Compare commits
2 commits
f20e5c4d39
...
e7fbd6ff86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7fbd6ff86 | ||
|
|
7adeb59b06 |
28 changed files with 1116 additions and 192 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,8 +2,14 @@
|
|||
|
||||
## Unreleased — 0.1.6
|
||||
|
||||
Development opened after the stable v0.1.5 release. No 0.1.6 feature tranche
|
||||
is selected by this version-opening commit.
|
||||
Development opened after the stable v0.1.5 release. The Web refinement tranche
|
||||
adds persistent French/English settings, denser shared surfaces, ISO-week
|
||||
Dashboard activity, factual exercise-count overview charts, measured-timeline
|
||||
muscle-group drill-down, normalized measurement summaries, one shared refined
|
||||
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
|
||||
lists factual references and transactionally absorbs a custom duplicate into a
|
||||
canonical identity while retaining history/MAX context and recording a causal
|
||||
deletion marker; immutable embedded drafts cause an explicit conflict.
|
||||
|
||||
## 0.1.5 — 2026-09-23
|
||||
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ parallel implementation of its rules.
|
|||
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
|
||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||
| Current operational cursor | 0.1.6 development is open after immutable stable v0.1.5; no new feature tranche is selected by the version-opening commit. |
|
||||
| Current operational cursor | 0.1.6 Web UI refinement implemented; validation and private deployment review are in progress after immutable stable v0.1.5. |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
@ -306,6 +306,22 @@ the result. The Web aggregate now exposes the distinct factual snapshot through
|
|||
|
||||
## Local Web
|
||||
|
||||
The 0.1.6 refinement adds a persistent French/English preference and a dedicated
|
||||
Settings route without moving the global synchronization action. Dashboard
|
||||
activity is grouped by calendar month/ISO week, its progression tile is now a
|
||||
global factual synthesis, and Analyse progresses from primary BODY ZONE to
|
||||
exercise to individual detail. Exercise work time is derived exclusively from
|
||||
`session_exercise_timeline` start/end facts; missing timelines remain unknown.
|
||||
Measurements first expose unit-safe relative change cards before real-unit
|
||||
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
|
||||
|
||||
The Equipment route projects supplied and custom definitions with exercise,
|
||||
history, preparation and Program reference counts. Its guarded merge accepts a
|
||||
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
|
||||
transaction, preserves occurrence/MAX facts indirectly through occurrence
|
||||
identity, and writes the existing causal deletion boundary. An immutable
|
||||
execution-draft JSON reference rejects the operation rather than being edited.
|
||||
|
||||
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
|
||||
browser shell, Dashboard, layout ownership, build/runtime separation, and
|
||||
security invariants are canonical. The CLI/local HTTP infrastructure and
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ The heart-rate path remains independent from the frozen Bluetooth Trainlog
|
|||
synchronization transport.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=0.1.6 development open; next feature tranche not selected.
|
||||
CURRENT_OPERATIONAL_CURSOR=0.1.6 Web UI refinement implemented; validation and private deployment review pending.
|
||||
```
|
||||
|
||||
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
# Tests and validation
|
||||
|
||||
## Web UI refinement 0.1.6
|
||||
|
||||
Web regressions cover calendar-week activity grouping, global rather than
|
||||
single-exercise Dashboard progression, exercises-per-date overview data,
|
||||
measured-only group drill-down, normalized measurement selection, the shared
|
||||
BODY ZONES figure, persistent language/settings navigation, multi-night Sleep
|
||||
selection invariants, and Equipment response validation. Native equipment
|
||||
coverage exercises a synthetic custom duplicate and proves that a transactional
|
||||
merge repoints completed history to the retained canonical identity without
|
||||
touching real user equipment. Strict C compilation, the full Meson inventory,
|
||||
Web typecheck/build/Vitest, JSON/import validation, changed-C formatting and
|
||||
diff checks remain the closeout gates.
|
||||
|
||||
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
||||
offsets, revision conflict, logical deletion/reopen, companion replay and
|
||||
rollback, Web periods/deep links, Android persistence, and vector PDF
|
||||
|
|
|
|||
27
tui/include/trainlog/web_equipment.h
Normal file
27
tui/include/trainlog/web_equipment.h
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
280
tui/src/web_equipment.c
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
22
web/src/api/equipment.test.ts
Normal file
22
web/src/api/equipment.test.ts
Normal 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
53
web/src/api/equipment.ts
Normal 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())
|
||||
}
|
||||
|
|
@ -163,7 +163,7 @@ describe('shell Trainlog', () => {
|
|||
it('expose les cinq destinations dans une navigation clavier sémantique', () => {
|
||||
mockHealth()
|
||||
render(<App />)
|
||||
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(5)
|
||||
expect(screen.getAllByRole('link').filter((link) => link.closest('nav'))).toHaveLength(6)
|
||||
expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
|
|||
import { SessionsPage } from '../routes/SessionsPage'
|
||||
import { ExercisesPage } from '../routes/ExercisesPage'
|
||||
import { AnalysisPage } from '../routes/AnalysisPage'
|
||||
import { SettingsPage } from '../routes/SettingsPage'
|
||||
import { EquipmentPage } from '../routes/EquipmentPage'
|
||||
import type { RouteId } from './routes'
|
||||
import { useRoute } from './useRoute'
|
||||
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||
import { fetchAnalysis, type AnalysisSnapshot } from '../api/analysis'
|
||||
import { LanguagePreferencesProvider, useLanguagePreferences } from '../presentation/LanguagePreferences'
|
||||
|
||||
async function fetchDashboardAnalysis(signal?: AbortSignal): Promise<AnalysisSnapshot> {
|
||||
const [windowed, allMeasurements] = await Promise.all([
|
||||
|
|
@ -23,11 +26,12 @@ async function fetchDashboardAnalysis(signal?: AbortSignal): Promise<AnalysisSna
|
|||
return { ...windowed, measurements: allMeasurements.measurements }
|
||||
}
|
||||
|
||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'analysis' | 'programs' | 'exercises'>, [string, string, string]> = {
|
||||
const placeholderContent: Record<Extract<RouteId, 'sessions'>, [string, string, string]> = {
|
||||
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const { language } = useLanguagePreferences()
|
||||
const [route, navigate] = useRoute()
|
||||
const [health, setHealth] = useState<Health | null>(null)
|
||||
const [healthPending, setHealthPending] = useState(true)
|
||||
|
|
@ -105,13 +109,15 @@ function AppContent() {
|
|||
route.id === 'sessions' ? <SessionsPage /> :
|
||||
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
|
||||
route.id === 'exercises' ? <ExercisesPage path={route.path} onNavigate={navigate} /> :
|
||||
route.id === 'equipment' ? <EquipmentPage /> :
|
||||
route.id === 'settings' ? <SettingsPage /> :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
eyebrow={placeholderContent[route.id][1]}
|
||||
description={placeholderContent[route.id][2]} />
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Aller au contenu</a>
|
||||
<a className="skip-link" href="#main-content">{language === 'en' ? 'Skip to content' : 'Aller au contenu'}</a>
|
||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
|
||||
reloadDashboard()
|
||||
reloadPreparedItems()
|
||||
|
|
@ -124,5 +130,5 @@ function AppContent() {
|
|||
}
|
||||
|
||||
export function App() {
|
||||
return <DatePreferencesProvider><AppContent /></DatePreferencesProvider>
|
||||
return <LanguagePreferencesProvider><DatePreferencesProvider><AppContent /></DatePreferencesProvider></LanguagePreferencesProvider>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type RouteId = 'dashboard' | 'analysis' | 'programs' | 'sessions' | 'exercises'
|
||||
export type RouteId = 'dashboard' | 'analysis' | 'programs' | 'sessions' | 'exercises' | 'equipment' | 'settings'
|
||||
|
||||
export interface AppRoute {
|
||||
id: RouteId
|
||||
|
|
@ -12,6 +12,7 @@ export const routes: readonly AppRoute[] = [
|
|||
{ id: 'programs', path: '/programmes', label: 'Programmes' },
|
||||
{ id: 'sessions', path: '/seances', label: 'Séances' },
|
||||
{ id: 'exercises', path: '/exercices', label: 'Exercices' },
|
||||
{ id: 'equipment', path: '/equipements', label: 'Équipements' },
|
||||
]
|
||||
|
||||
export function routeFromPath(pathname: string): AppRoute {
|
||||
|
|
@ -24,5 +25,7 @@ export function routeFromPath(pathname: string): AppRoute {
|
|||
if (pathname === '/exercices' || pathname.startsWith('/exercices/')) {
|
||||
return { ...routes[4], path: pathname }
|
||||
}
|
||||
if (pathname === '/equipements' || pathname.startsWith('/equipements/')) return routes[5]
|
||||
if (pathname === '/parametres') return { id: 'settings', path: '/parametres', label: 'Paramètres' }
|
||||
return routes.find((route) => route.path === pathname) ?? routes[0]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { MouseEvent } from 'react'
|
||||
import { routes, type AppRoute } from '../app/routes'
|
||||
import { SyncControl } from './SyncControl'
|
||||
import { DisplayPreferences } from './DisplayPreferences'
|
||||
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
|
||||
|
||||
interface HeaderProps {
|
||||
activeRoute: AppRoute
|
||||
|
|
@ -10,6 +10,11 @@ interface HeaderProps {
|
|||
}
|
||||
|
||||
export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps) {
|
||||
const { language } = useLanguagePreferences()
|
||||
const labels: Record<string, string> = language === 'en' ? {
|
||||
dashboard: 'Dashboard', analysis: 'Analysis', programs: 'Programs', sessions: 'Sessions',
|
||||
exercises: 'Exercises', equipment: 'Equipment',
|
||||
} : {}
|
||||
const follow = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
|
||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
|
|
@ -23,17 +28,18 @@ export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps
|
|||
<span className="brand-mark" aria-hidden="true">TL</span>
|
||||
<span>TRAINLOG</span>
|
||||
</a>
|
||||
<nav className="main-nav" aria-label="Navigation principale">
|
||||
<nav className="main-nav" aria-label={language === 'en' ? 'Main navigation' : 'Navigation principale'}>
|
||||
{routes.map((route) => (
|
||||
<a key={route.id} href={route.path}
|
||||
className={activeRoute.id === route.id ? 'nav-link is-active' : 'nav-link'}
|
||||
aria-current={activeRoute.id === route.id ? 'page' : undefined}
|
||||
onClick={(event) => follow(event, route.path)}>
|
||||
{route.label}
|
||||
{labels[route.id] ?? route.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<DisplayPreferences />
|
||||
<a href="/parametres" className={activeRoute.id === 'settings' ? 'nav-link settings-link is-active' : 'nav-link settings-link'}
|
||||
onClick={(event) => follow(event, '/parametres')}>⚙ {language === 'en' ? 'Settings' : 'Paramètres'}</a>
|
||||
<SyncControl onCommitted={onSyncCommitted} />
|
||||
</header>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ type RegionProps = (zoneId: MappedBodyZoneId) => Record<string, unknown>
|
|||
|
||||
function BodyView({ title, side, zoneProps, language }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps; language: 'fr' | 'en' }) {
|
||||
return <figure><figcaption>{title}</figcaption><svg viewBox="0 0 120 260" role="group" aria-label={language === 'fr' ? `Silhouette, vue ${title.toLowerCase()}` : `Silhouette, ${title.toLowerCase()} view`}>
|
||||
<circle className="body-outline" cx="60" cy="23" r="16" />
|
||||
<path className="body-outline" d="M42 43 Q60 36 78 43 L88 116 75 150 70 244H52L45 150 32 116Z" />
|
||||
<path className="body-outline" d="M60 5C49 5 43 13 44 24c1 10 7 17 16 17s15-7 16-17C77 13 71 5 60 5Z" />
|
||||
<path className="body-outline" d="M53 40v7c-13 2-21 8-25 20l-9 55c-1 7 2 11 8 12 5 1 9-3 10-9l7-43 1 49c0 8-5 17-7 27l-4 38 9 52c1 6 6 9 11 7 4-1 6-5 6-10v-43l4-28 4 28v43c0 5 2 9 6 10 5 2 10-1 11-7l9-52-4-38c-2-10-7-19-7-27l1-49 7 43c1 6 5 10 10 9 6-1 9-5 8-12l-9-55c-4-12-12-18-25-20v-7Z" />
|
||||
{side === 'front' ? <>
|
||||
<ellipse id="front-shoulder-left" {...zoneProps('shoulders')} cx="37" cy="57" rx="10" ry="12" /><ellipse id="front-shoulder-right" {...zoneProps('shoulders')} cx="83" cy="57" rx="10" ry="12" />
|
||||
<path id="front-chest-left" {...zoneProps('chest')} d="M43 58 Q52 53 58 59 L57 85 Q47 85 42 77Z" /><path id="front-chest-right" {...zoneProps('chest')} d="M77 58 Q68 53 62 59 L63 85 Q73 85 78 77Z" />
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ describe("tuiles Dashboard alimentées par le contrat", () => {
|
|||
expect(screen.queryByText(/%/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("masque les séries incompatibles et humanise la durée pour une activité continue", () => {
|
||||
it("remplace l’exercice arbitraire par une synthèse globale factuelle", () => {
|
||||
const analysis = {
|
||||
...analysisFixture,
|
||||
exercise: {
|
||||
|
|
@ -252,10 +252,9 @@ describe("tuiles Dashboard alimentées par le contrat", () => {
|
|||
analysis={analysis}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("15 min")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,43 km")).toBeInTheDocument();
|
||||
expect(screen.getByText("6,4 km/h")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Séries")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Exercices distincts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Explorer dans Analyse")).toBeInTheDocument();
|
||||
expect(screen.queryByText("15 min")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("réserve la courbe aux tailles medium et large", async () => {
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
|
|||
analysis?.overview.sets ??
|
||||
days.reduce((total, day) => total + day.set_count, 0);
|
||||
const shown = size === "compact" ? days.slice(-35) : days;
|
||||
const weeks = calendarWeeks(shown);
|
||||
return (
|
||||
<div className="activity-content">
|
||||
<div className="metric-row">
|
||||
|
|
@ -255,17 +256,19 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
|
|||
)}
|
||||
</div>
|
||||
<div
|
||||
className="activity-heatmap"
|
||||
className="activity-calendar"
|
||||
role="img"
|
||||
aria-label={`Sur 90 jours : ${count(activeDays, "jour actif", "jours actifs")}, ${count(sessions, "séance")}, ${count(sets, "série")}.`}
|
||||
>
|
||||
{shown.map((day) => (
|
||||
<span
|
||||
key={day.date}
|
||||
className={`activity-day${day.active ? " is-active" : ""}${day.session_count > 1 ? " is-multiple" : ""}`}
|
||||
title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`}
|
||||
/>
|
||||
))}
|
||||
{weeks.map((week) => <section className="activity-week" key={week.key}>
|
||||
<h4>{week.label}</h4><div className="activity-week-days">{week.days.map((day) => (
|
||||
<span key={day.date} className={`activity-day activity-calendar-day${day.active ? " is-active" : ""}${day.session_count > 1 ? " is-multiple" : ""}`}
|
||||
title={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`}
|
||||
aria-label={`${formatDate(day.date, dateFormat)} — ${count(day.session_count, "séance")}, ${count(day.set_count, "série")}`}>
|
||||
<small>{activityDayLabel(day.date)}</small><strong>{/^\d{4}-\d{2}-\d{2}$/.test(day.date) ? Number(day.date.slice(8, 10)) : '—'}</strong>
|
||||
{day.active && <b>{day.session_count}</b>}
|
||||
</span>))}</div>
|
||||
</section>)}
|
||||
</div>
|
||||
{size === "large" && (
|
||||
<p className="chart-legend">
|
||||
|
|
@ -277,71 +280,51 @@ export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function activityDayLabel(value: string): string {
|
||||
const date = new Date(`${value}T12:00:00Z`)
|
||||
return Number.isFinite(date.getTime())
|
||||
? new Intl.DateTimeFormat('fr-FR', { weekday: 'short', timeZone: 'UTC' }).format(date)
|
||||
: '—'
|
||||
}
|
||||
|
||||
export function calendarWeeks(days: DashboardSnapshot['data']['activity']['days']) {
|
||||
const groups = new Map<string, typeof days>()
|
||||
days.forEach((day, index) => {
|
||||
const date = new Date(`${day.date}T12:00:00Z`)
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
const key = `unknown-${Math.floor(index / 7)}`
|
||||
groups.set(key, [...(groups.get(key) ?? []), day])
|
||||
return
|
||||
}
|
||||
const weekday = date.getUTCDay() || 7
|
||||
const thursday = new Date(date)
|
||||
thursday.setUTCDate(date.getUTCDate() + 4 - weekday)
|
||||
const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1, 12))
|
||||
const week = Math.ceil((((thursday.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
||||
const key = `${thursday.getUTCFullYear()}-${String(week).padStart(2, '0')}`
|
||||
groups.set(key, [...(groups.get(key) ?? []), day])
|
||||
})
|
||||
return [...groups.entries()].map(([key, weekDays]) => ({
|
||||
key,
|
||||
days: weekDays,
|
||||
label: key.startsWith('unknown-') ? 'Période' : `${new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${weekDays[0].date}T12:00:00Z`))} — S${Number(key.slice(-2))}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ProgressionTile({ snapshot, size, analysis }: TileDataProps) {
|
||||
const { dateFormat } = useDatePreferences();
|
||||
if (analysis?.exercise !== null && analysis?.exercise !== undefined) {
|
||||
const exercise = analysis.exercise;
|
||||
const latest = exercise.points[0];
|
||||
const distinctExercises = analysis?.overview.distinct_exercises ?? 0;
|
||||
if (analysis !== null && analysis !== undefined) {
|
||||
const top = analysis.exercise_groups.flatMap((group) => group.exercises).sort((left, right) => right.occurrences - left.occurrences || left.name.localeCompare(right.name, 'fr')).slice(0, 3);
|
||||
return (
|
||||
<div className="progression-content">
|
||||
<div>
|
||||
<p className="primary-label">{exercise.name}</p>
|
||||
<p className="secondary-label">
|
||||
{exercise.tracking_mode === "reps" ? "Répétitions" : "Durée"}
|
||||
</p>
|
||||
</div>
|
||||
{latest === undefined ? (
|
||||
<Unavailable text="Aucune donnée compatible sur 30 jours" />
|
||||
) : (
|
||||
<dl className="fact-list horizontal">
|
||||
{exercise.recording_mode === "sets" && (
|
||||
<Fact label="Séries" value={String(latest.sets)} />
|
||||
)}
|
||||
{latest.reps !== null && (
|
||||
<Fact label="Répétitions" value={String(latest.reps)} />
|
||||
)}
|
||||
{(latest.continuous_duration_seconds ??
|
||||
latest.set_duration_seconds) !== null && (
|
||||
<Fact
|
||||
label="Durée"
|
||||
value={formatDuration(
|
||||
latest.continuous_duration_seconds ??
|
||||
latest.set_duration_seconds ??
|
||||
0,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{latest.distance_km !== null && (
|
||||
<Fact
|
||||
label="Distance"
|
||||
value={`${latest.distance_km.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} km`}
|
||||
/>
|
||||
)}
|
||||
{latest.speed_kmh !== null && (
|
||||
<Fact
|
||||
label="Vitesse"
|
||||
value={`${latest.speed_kmh.toLocaleString("fr-FR", { maximumFractionDigits: 2 })} km/h`}
|
||||
/>
|
||||
)}
|
||||
{latest.external_volume_kg !== null && (
|
||||
<Fact
|
||||
label="Volume externe"
|
||||
value={formatWeight(latest.external_volume_kg)}
|
||||
/>
|
||||
)}
|
||||
{latest.explicit_max_kg !== null && (
|
||||
<Fact
|
||||
label="MAX explicite"
|
||||
value={formatWeight(latest.explicit_max_kg)}
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
<dl className="fact-list horizontal"><Fact label="Exercices distincts" value={String(distinctExercises)} /><Fact label="Séances" value={String(analysis.overview.sessions)} /><Fact label="Séries" value={String(analysis.overview.sets)} /></dl>
|
||||
{size !== 'compact' && top.length > 0 && <ol className="progression-top">{top.map((exercise) => <li key={exercise.exercise_id}><span>{exercise.name}</span><strong>{exercise.occurrences} occurrences</strong></li>)}</ol>}
|
||||
<a
|
||||
className="tile-context-link"
|
||||
href={`/analyse?section=exercise&exercise_id=${encodeURIComponent(exercise.exercise_id)}&period=30d`}
|
||||
href="/analyse?section=exercise&period=30d"
|
||||
>
|
||||
Voir dans Analyse
|
||||
Explorer dans Analyse
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
35
web/src/presentation/LanguagePreferences.tsx
Normal file
35
web/src/presentation/LanguagePreferences.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
export type WebLanguage = 'fr' | 'en'
|
||||
|
||||
const STORAGE_KEY = 'trainlog.web.language.v1'
|
||||
|
||||
interface LanguageContextValue {
|
||||
language: WebLanguage
|
||||
setLanguage: (language: WebLanguage) => void
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | null>(null)
|
||||
|
||||
export function LanguagePreferencesProvider({ children }: { children: ReactNode }) {
|
||||
const [language, updateLanguage] = useState<WebLanguage>(() =>
|
||||
window.localStorage.getItem(STORAGE_KEY) === 'en' ? 'en' : 'fr')
|
||||
|
||||
const setLanguage = (value: WebLanguage) => {
|
||||
window.localStorage.setItem(STORAGE_KEY, value)
|
||||
updateLanguage(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
}, [language])
|
||||
|
||||
const value = useMemo(() => ({ language, setLanguage }), [language])
|
||||
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
}
|
||||
|
||||
export function useLanguagePreferences(): LanguageContextValue {
|
||||
const value = useContext(LanguageContext)
|
||||
if (value === null) throw new Error('LanguagePreferencesProvider is missing')
|
||||
return value
|
||||
}
|
||||
|
|
@ -190,15 +190,15 @@ describe("AnalysisPage", () => {
|
|||
expect(document.querySelectorAll(".chart-x-label")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses a factual sessions-by-date histogram in the overview", async () => {
|
||||
it("uses a factual exercises-by-date histogram in the overview", async () => {
|
||||
render(<AnalysisPage />);
|
||||
expect(await screen.findByText("Séances par date")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Exercices réalisés par date")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Histogramme des séances par date" }),
|
||||
screen.getByRole("img", { name: "Histogramme des exercices réalisés par date" }),
|
||||
).toBeInTheDocument();
|
||||
expect(document.querySelector(".activity-bar")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("2 séance(s)"),
|
||||
expect.stringContaining("1 exercice(s)"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -342,33 +342,33 @@ function ActivityHistogram({
|
|||
const ordered = [...points].sort((left, right) =>
|
||||
left.date.localeCompare(right.date),
|
||||
);
|
||||
const maximum = Math.max(...ordered.map((point) => point.sessions), 1);
|
||||
const maximum = Math.max(...ordered.map((point) => point.exercises), 1);
|
||||
return (
|
||||
<figure className="activity-histogram">
|
||||
<div className="analysis-chart-heading">
|
||||
<strong>
|
||||
{language === "fr" ? "Séances par date" : "Sessions by date"}
|
||||
{language === "fr" ? "Exercices réalisés par date" : "Exercises completed by date"}
|
||||
</strong>
|
||||
<span>{language === "fr" ? "séances" : "sessions"}</span>
|
||||
<span>{language === "fr" ? "exercices" : "exercises"}</span>
|
||||
</div>
|
||||
<div
|
||||
className="activity-bars"
|
||||
role="img"
|
||||
aria-label={
|
||||
language === "fr"
|
||||
? "Histogramme des séances par date"
|
||||
: "Sessions-by-date histogram"
|
||||
? "Histogramme des exercices réalisés par date"
|
||||
: "Exercises-by-date histogram"
|
||||
}
|
||||
>
|
||||
{ordered.map((point, index) => (
|
||||
<div className="activity-bar-column" key={point.date}>
|
||||
<span className="activity-bar-value">{point.sessions}</span>
|
||||
<span className="activity-bar-value">{point.exercises}</span>
|
||||
<span
|
||||
className="activity-bar"
|
||||
style={{
|
||||
height: `${Math.max(8, (point.sessions / maximum) * 100)}%`,
|
||||
height: `${Math.max(8, (point.exercises / maximum) * 100)}%`,
|
||||
}}
|
||||
title={`${shortDate(`${point.date}T00:00:00Z`, language)} · ${point.sessions} ${language === "fr" ? "séance(s)" : "session(s)"}`}
|
||||
title={`${shortDate(`${point.date}T00:00:00Z`, language)} · ${point.exercises} ${language === "fr" ? "exercice(s)" : "exercise(s)"} · ${point.sets} ${language === "fr" ? "série(s)" : "set(s)"}`}
|
||||
/>
|
||||
{(ordered.length <= 10 ||
|
||||
index === 0 ||
|
||||
|
|
@ -518,6 +518,7 @@ export function AnalysisPage() {
|
|||
const [exerciseMetric, setExerciseMetric] = useState<ExerciseMetric | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(null);
|
||||
const [pending, setPending] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
|
@ -595,6 +596,8 @@ export function AnalysisPage() {
|
|||
1,
|
||||
...(snapshot?.body_zones.map((zone) => zone.exposures) ?? []),
|
||||
);
|
||||
const distinctExercises = snapshot?.overview.distinct_exercises ?? 0;
|
||||
const activeGroup = snapshot?.exercise_groups.find((group) => group.zone_id === selectedGroup);
|
||||
return (
|
||||
<section className="page analysis-page" aria-labelledby="page-title">
|
||||
<div className="page-heading">
|
||||
|
|
@ -666,6 +669,10 @@ export function AnalysisPage() {
|
|||
label={t.sets}
|
||||
value={formatNumber(snapshot.overview.sets, language)}
|
||||
/>
|
||||
<Fact
|
||||
label={language === "fr" ? "Exercices distincts" : "Distinct exercises"}
|
||||
value={formatNumber(distinctExercises, language)}
|
||||
/>
|
||||
{snapshot.overview.duration_seconds !== null && (
|
||||
<Fact
|
||||
label={t.duration}
|
||||
|
|
@ -732,17 +739,13 @@ export function AnalysisPage() {
|
|||
</article>
|
||||
<article className="analysis-panel">
|
||||
<h2>{t.exercises}</h2>
|
||||
{snapshot.exercise === null ? (
|
||||
{distinctExercises === 0 ? (
|
||||
<p className="analysis-empty">{t.noExercise}</p>
|
||||
) : (
|
||||
<>
|
||||
<h3>{snapshot.exercise.name}</h3>
|
||||
<ExerciseFacts
|
||||
exercise={snapshot.exercise}
|
||||
language={language}
|
||||
selectedMetric={exerciseMetric}
|
||||
onMetricChange={setExerciseMetric}
|
||||
/>
|
||||
<p className="analysis-summary-value">{distinctExercises}</p>
|
||||
<p className="analysis-note">{language === "fr" ? "exercices distincts sur la période" : "distinct exercises in this period"}</p>
|
||||
<button className="analysis-link" type="button" onClick={() => chooseSection("exercise")}>{language === "fr" ? "Explorer par groupe" : "Explore by group"}</button>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
|
@ -750,7 +753,32 @@ export function AnalysisPage() {
|
|||
)}
|
||||
{section === "exercise" && (
|
||||
<article className="analysis-panel analysis-wide">
|
||||
<label className="analysis-picker">
|
||||
{selectedGroup === null ? <>
|
||||
<h2>{language === "fr" ? "Groupes musculaires" : "Muscle groups"}</h2>
|
||||
<p className="analysis-note">{language === "fr" ? "Temps provenant uniquement des timestamps réels début/fin des exercices." : "Time comes only from real exercise start/end timestamps."}</p>
|
||||
<div className="analysis-group-bars">{snapshot.exercise_groups.map((group) => {
|
||||
const seconds = group.exercises.reduce((sum, item) => sum + item.measured_seconds, 0)
|
||||
const measured = group.exercises.reduce((sum, item) => sum + item.measured_occurrences, 0)
|
||||
const maximum = Math.max(1, ...snapshot.exercise_groups.map((candidate) => candidate.exercises.reduce((sum, item) => sum + item.measured_seconds, 0)))
|
||||
return <button type="button" key={group.zone_id} onClick={() => { setSelectedGroup(group.zone_id); setExerciseId(undefined) }}>
|
||||
<span>{language === "en" ? (zoneLabelsEn[group.zone_id] ?? group.label) : group.label}</span>
|
||||
<span className="group-bar-track"><span style={{ width: `${(seconds / maximum) * 100}%` }} /></span>
|
||||
<strong>{formatDuration(seconds)}</strong><small>{language === "fr" ? `Temps mesuré sur ${measured} occurrences` : `Time measured across ${measured} occurrences`}</small>
|
||||
</button>
|
||||
})}</div>
|
||||
</> : activeGroup !== undefined && exerciseId !== undefined && snapshot.exercise?.exercise_id === exerciseId ? <>
|
||||
<button type="button" className="analysis-back" onClick={() => setExerciseId(undefined)}>← {language === "fr" ? "Exercices du groupe" : "Group exercises"}</button>
|
||||
<h2>{snapshot.exercise.name}</h2>
|
||||
<ExerciseFacts exercise={snapshot.exercise} language={language} selectedMetric={exerciseMetric} onMetricChange={setExerciseMetric} detailed />
|
||||
</> : activeGroup !== undefined ? <>
|
||||
<button type="button" className="analysis-back" onClick={() => setSelectedGroup(null)}>← {language === "fr" ? "Groupes musculaires" : "Muscle groups"}</button>
|
||||
<h2>{language === "en" ? (zoneLabelsEn[activeGroup.zone_id] ?? activeGroup.label) : activeGroup.label}</h2>
|
||||
<div className="analysis-group-bars">{activeGroup.exercises.map((exercise) => <button type="button" key={exercise.exercise_id} onClick={() => chooseExercise(exercise.exercise_id)}>
|
||||
<span>{exercise.name}</span><strong>{formatDuration(exercise.measured_seconds)}</strong>
|
||||
<small>{exercise.occurrences} {language === "fr" ? "occurrences" : "occurrences"} · {exercise.sessions} {language === "fr" ? "séances" : "sessions"} · {exercise.sets} {language === "fr" ? "séries" : "sets"}<br/>{language === "fr" ? `Temps mesuré sur ${exercise.measured_occurrences} occurrences` : `Time measured across ${exercise.measured_occurrences} occurrences`}</small>
|
||||
</button>)}</div>
|
||||
</> : null}
|
||||
<label className="analysis-picker legacy-exercise-picker">
|
||||
{t.exercise}
|
||||
<select
|
||||
value={snapshot.exercise?.exercise_id ?? ""}
|
||||
|
|
@ -769,9 +797,9 @@ export function AnalysisPage() {
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
{snapshot.exercise === null ? (
|
||||
{selectedGroup === null && snapshot.exercise === null ? (
|
||||
<p className="analysis-empty">{t.noExercise}</p>
|
||||
) : (
|
||||
) : selectedGroup === null && snapshot.exercise !== null ? (
|
||||
<>
|
||||
<h2>{snapshot.exercise.name}</h2>
|
||||
<ExerciseFacts
|
||||
|
|
@ -782,7 +810,7 @@ export function AnalysisPage() {
|
|||
detailed
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</article>
|
||||
)}
|
||||
{section === "body-zones" && (
|
||||
|
|
@ -827,6 +855,11 @@ export function AnalysisPage() {
|
|||
)}
|
||||
{section === "measurements" && (
|
||||
<article className="analysis-panel analysis-wide">
|
||||
<h2>{language === "fr" ? "Vue globale" : "Overview"}</h2>
|
||||
<div className="measurement-overview">{snapshot.measurements.summaries.filter((summary) => summary.count > 0).map((summary) => {
|
||||
const relative = summary.first !== null && summary.last !== null && summary.first !== 0 ? ((summary.last - summary.first) / Math.abs(summary.first)) * 100 : null
|
||||
return <button type="button" key={summary.metric} aria-pressed={metric === summary.metric} onClick={() => chooseMetric(summary.metric)}><strong>{metricLabels[language][summary.metric]}</strong><span>{summary.first} {summary.unit} → {summary.last} {summary.unit}</span><em>{relative === null ? '—' : `${relative > 0 ? '+' : ''}${formatNumber(relative, language, 1)} %`}</em></button>
|
||||
})}</div>
|
||||
<label className="analysis-picker">
|
||||
{t.metric}
|
||||
<select
|
||||
|
|
|
|||
64
web/src/routes/EquipmentPage.tsx
Normal file
64
web/src/routes/EquipmentPage.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { fetchEquipment, mergeEquipment, type EquipmentSnapshot } from '../api/equipment'
|
||||
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
|
||||
|
||||
export function EquipmentPage() {
|
||||
const { language } = useLanguagePreferences()
|
||||
const english = language === 'en'
|
||||
const [snapshot, setSnapshot] = useState<EquipmentSnapshot | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [canonicalId, setCanonicalId] = useState('')
|
||||
const [duplicateId, setDuplicateId] = useState('')
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [mutationError, setMutationError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetchEquipment(controller.signal).then(setSnapshot).catch(() => setFailed(true))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
const canonical = snapshot?.items.find((item) => item.equipment_id === canonicalId)
|
||||
const duplicate = snapshot?.items.find((item) => item.equipment_id === duplicateId)
|
||||
const canMerge = canonical !== undefined && duplicate !== undefined && canonicalId !== duplicateId
|
||||
const performMerge = async () => {
|
||||
if (!canMerge) return
|
||||
setBusy(true); setMutationError('')
|
||||
try { setSnapshot(await mergeEquipment(canonicalId, duplicateId)); setConfirming(false); setDuplicateId('') }
|
||||
catch (error) { setMutationError(error instanceof Error ? error.message : 'equipment_merge_failed') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
return <section className="page equipment-page" aria-labelledby="page-title">
|
||||
<div className="page-heading"><div><p className="eyebrow">{english ? 'HARDWARE' : 'MATÉRIEL'}</p>
|
||||
<h1 id="page-title">{english ? 'Equipment' : 'Équipements'}</h1></div></div>
|
||||
{failed && <p className="error-panel" role="alert">{english ? 'Equipment is unavailable.' : 'Les équipements sont indisponibles.'}</p>}
|
||||
{snapshot === null && !failed && <p role="status">{english ? 'Loading…' : 'Chargement…'}</p>}
|
||||
{snapshot && <>
|
||||
<div className="equipment-grid">{snapshot.items.map((item) => <article className="equipment-card" key={item.equipment_id}>
|
||||
<div><span className="equipment-origin">{item.origin}</span><h2>{item.display_name}</h2></div>
|
||||
<p>{item.equipment_type} · {item.load_semantics}</p>
|
||||
<dl className="compact-facts"><div><dt>{english ? 'Exercises' : 'Exercices'}</dt><dd>{item.exercise_count}</dd></div>
|
||||
<div><dt>{english ? 'History' : 'Historique'}</dt><dd>{item.historical_occurrences}</dd></div></dl>
|
||||
</article>)}</div>
|
||||
<article className="equipment-merge-panel">
|
||||
<h2>{english ? 'Merge duplicates' : 'Fusionner des doublons'}</h2>
|
||||
<p>{english ? 'All legitimate references are moved atomically to the equipment you keep.' : 'Toutes les références légitimes sont transférées atomiquement vers l’équipement conservé.'}</p>
|
||||
<div className="equipment-merge-fields">
|
||||
<label>{english ? 'Keep' : 'Conserver'}<select value={canonicalId} onChange={(event) => { setCanonicalId(event.target.value); setConfirming(false) }}><option value="">—</option>{snapshot.items.map((item) => <option key={item.equipment_id} value={item.equipment_id}>{item.display_name}</option>)}</select></label>
|
||||
<label>{english ? 'Merge into it' : 'Fusionner dedans'}<select value={duplicateId} onChange={(event) => { setDuplicateId(event.target.value); setConfirming(false) }}><option value="">—</option>{snapshot.items.map((item) => <option key={item.equipment_id} value={item.equipment_id}>{item.display_name}</option>)}</select></label>
|
||||
</div>
|
||||
{canMerge && <div className="merge-consequences"><strong>{english ? 'Consequences' : 'Conséquences'}</strong><ul>
|
||||
<li>{duplicate.exercise_count} {english ? 'exercise associations' : 'associations exercice'}</li>
|
||||
<li>{duplicate.historical_occurrences} {english ? 'historical occurrences (facts, timestamps and MAX retained)' : 'occurrences historiques (faits, timestamps et MAX conservés)'}</li>
|
||||
<li>{duplicate.preparation_references} {english ? 'preparation references' : 'références de préparation'} · {duplicate.program_references} {english ? 'program references' : 'références de programme'}</li>
|
||||
</ul></div>}
|
||||
{!confirming ? <button type="button" className="danger-action" disabled={!canMerge} onClick={() => setConfirming(true)}>{english ? 'Review merge' : 'Vérifier la fusion'}</button> : <div className="merge-confirmation" role="alertdialog" aria-label={english ? 'Confirm equipment merge' : 'Confirmer la fusion des équipements'}>
|
||||
<p><strong>{english ? 'Keep' : 'Conserver'} :</strong> {canonical?.display_name}<br/><strong>{english ? 'Merge into it' : 'Fusionner dedans'} :</strong> {duplicate?.display_name}</p>
|
||||
<button type="button" className="danger-action" disabled={busy} onClick={() => void performMerge()}>{busy ? (english ? 'Merging…' : 'Fusion…') : (english ? 'Confirm irreversible merge' : 'Confirmer la fusion irréversible')}</button>
|
||||
<button type="button" className="quiet-action" disabled={busy} onClick={() => setConfirming(false)}>{english ? 'Cancel' : 'Annuler'}</button>
|
||||
</div>}
|
||||
{mutationError && <p className="form-error" role="alert">{mutationError}</p>}
|
||||
</article>
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
39
web/src/routes/SettingsPage.tsx
Normal file
39
web/src/routes/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||
import { useLanguagePreferences } from '../presentation/LanguagePreferences'
|
||||
|
||||
export function SettingsPage() {
|
||||
const { language, setLanguage } = useLanguagePreferences()
|
||||
const { dateFormat, pending, error, save } = useDatePreferences()
|
||||
const english = language === 'en'
|
||||
return <section className="page settings-page" aria-labelledby="page-title">
|
||||
<div className="page-heading"><div>
|
||||
<p className="eyebrow">{english ? 'PREFERENCES' : 'PRÉFÉRENCES'}</p>
|
||||
<h1 id="page-title">{english ? 'Settings' : 'Paramètres'}</h1>
|
||||
</div></div>
|
||||
<div className="settings-grid">
|
||||
<article className="settings-card">
|
||||
<h2>{english ? 'Language' : 'Langue'}</h2>
|
||||
<p>{english ? 'Language used throughout the Web interface.' : 'Langue utilisée dans toute l’interface Web.'}</p>
|
||||
<fieldset>
|
||||
<legend className="sr-only">{english ? 'Language' : 'Langue'}</legend>
|
||||
<label><input type="radio" name="language" checked={language === 'fr'} onChange={() => setLanguage('fr')} /> Français</label>
|
||||
<label><input type="radio" name="language" checked={language === 'en'} onChange={() => setLanguage('en')} /> English</label>
|
||||
</fieldset>
|
||||
</article>
|
||||
<article className="settings-card">
|
||||
<h2>{english ? 'Appearance' : 'Apparence'}</h2>
|
||||
<fieldset disabled={pending}>
|
||||
<legend>{english ? 'Date format' : 'Format des dates'}</legend>
|
||||
<label><input type="radio" name="date-format" checked={dateFormat === 'fr'} onChange={() => void save('fr').catch(() => undefined)} /> {english ? 'French — 17/09/2026' : 'Français — 17/09/2026'}</label>
|
||||
<label><input type="radio" name="date-format" checked={dateFormat === 'iso'} onChange={() => void save('iso').catch(() => undefined)} /> ISO — 2026-09-17</label>
|
||||
</fieldset>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
</article>
|
||||
<article className="settings-card">
|
||||
<h2>Dashboard</h2>
|
||||
<p>{english ? 'Tile visibility and layout remain available directly on the Dashboard.' : 'La visibilité et l’agencement des cartes restent accessibles directement sur le Dashboard.'}</p>
|
||||
<a className="analysis-action" href="/">{english ? 'Open Dashboard' : 'Ouvrir le Dashboard'}</a>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
|
@ -349,7 +349,8 @@ h2 {
|
|||
align-self: start;
|
||||
border: 1px solid var(--surface-1);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--mantle);
|
||||
background: color-mix(in srgb, var(--base) 78%, var(--surface-0));
|
||||
box-shadow: 0 0.45rem 1.4rem rgb(0 0 0 / 10%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.analysis-panel h3 {
|
||||
|
|
@ -1617,6 +1618,86 @@ h2 {
|
|||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
}
|
||||
.program-calendar-header h1 {
|
||||
max-width: 24ch;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: clamp(2.15rem, 4vw, 2.8rem);
|
||||
line-height: 1.02;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.settings-grid,
|
||||
.equipment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.settings-card,
|
||||
.equipment-card,
|
||||
.equipment-merge-panel {
|
||||
padding: clamp(1rem, 2vw, 1.4rem);
|
||||
background: color-mix(in srgb, var(--base) 76%, var(--surface-0));
|
||||
border: 1px solid var(--surface-1);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 0.45rem 1.4rem rgb(0 0 0 / 10%);
|
||||
}
|
||||
.settings-card fieldset {
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: .65rem;
|
||||
border: 0;
|
||||
}
|
||||
.settings-card label { display: flex; gap: .55rem; align-items: center; }
|
||||
.settings-card p,
|
||||
.equipment-card p { color: var(--subtext-0); line-height: 1.55; }
|
||||
.equipment-origin { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; }
|
||||
.equipment-card h2 { margin: .35rem 0 0; }
|
||||
.compact-facts { margin: 1rem 0 0; display: flex; gap: 1.5rem; }
|
||||
.compact-facts dt { color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||
.compact-facts dd { margin: .2rem 0 0; font-weight: 750; }
|
||||
.equipment-merge-panel { margin-top: 1rem; }
|
||||
.equipment-merge-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.equipment-merge-fields label { display: grid; gap: .35rem; color: var(--subtext-0); }
|
||||
.equipment-merge-fields select { min-height: 2.7rem; padding: .5rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .5rem; }
|
||||
.merge-consequences { margin: 1rem 0; padding: .9rem; background: var(--mantle); border-radius: .55rem; }
|
||||
.merge-confirmation { margin-top: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .55rem; }
|
||||
.merge-confirmation .quiet-action { margin-left: .6rem; }
|
||||
|
||||
.activity-calendar { max-height: 22rem; margin-top: 1rem; overflow: auto; display: grid; gap: .75rem; }
|
||||
.activity-week h4 { margin: 0 0 .35rem; color: var(--subtext-0); font-size: var(--font-size-xs); text-transform: capitalize; }
|
||||
.activity-week-days { display: grid; grid-template-columns: repeat(7, minmax(2.4rem, 1fr)); gap: .3rem; }
|
||||
.activity-calendar-day { min-height: 3.15rem; padding: .35rem; position: relative; display: grid; align-content: space-between; color: var(--overlay-1); background: var(--base); border: 1px solid var(--surface-1); border-radius: .42rem; }
|
||||
.activity-calendar-day small { text-transform: capitalize; }
|
||||
.activity-calendar-day.is-active { color: var(--text); background: rgb(180 190 254 / 18%); border-color: var(--lavender); }
|
||||
.activity-calendar-day.is-multiple { background: rgb(203 166 247 / 27%); }
|
||||
.activity-calendar-day b { position: absolute; right: .35rem; bottom: .35rem; color: var(--lavender); font-size: .7rem; }
|
||||
.progression-top { margin: .8rem 0; padding: 0; display: grid; gap: .4rem; list-style: none; }
|
||||
.progression-top li { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.progression-top strong { color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||
|
||||
.analysis-summary-value { margin: .6rem 0 0; font-size: 2.5rem; font-weight: 750; }
|
||||
.analysis-group-bars { display: grid; gap: .55rem; }
|
||||
.analysis-group-bars > button { padding: .75rem; display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 2fr) auto; gap: .4rem 1rem; align-items: center; color: var(--text); text-align: left; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; cursor: pointer; }
|
||||
.analysis-group-bars > button:hover { border-color: var(--lavender); }
|
||||
.analysis-group-bars small { grid-column: 1 / -1; color: var(--subtext-0); }
|
||||
.group-bar-track { height: .55rem; overflow: hidden; background: var(--surface-0); border-radius: 1rem; }
|
||||
.group-bar-track > span { height: 100%; display: block; background: var(--lavender); border-radius: inherit; }
|
||||
.analysis-back { margin-bottom: 1rem; color: var(--lavender); background: transparent; border: 0; cursor: pointer; }
|
||||
.legacy-exercise-picker { margin-top: 1.5rem; }
|
||||
.measurement-overview { margin-bottom: 1.25rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .55rem; }
|
||||
.measurement-overview button { padding: .75rem; display: grid; gap: .3rem; color: var(--text); text-align: left; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; cursor: pointer; }
|
||||
.measurement-overview button[aria-pressed="true"] { border-color: var(--lavender); }
|
||||
.measurement-overview span { color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||
.measurement-overview em { color: var(--lavender); font-style: normal; font-weight: 700; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.equipment-merge-fields { grid-template-columns: 1fr; }
|
||||
.activity-week-days { grid-template-columns: repeat(7, minmax(2rem, 1fr)); }
|
||||
.analysis-group-bars > button { grid-template-columns: 1fr auto; }
|
||||
.group-bar-track { grid-column: 1 / -1; }
|
||||
}
|
||||
.program-calendar-summary {
|
||||
margin: 0.7rem 0 0;
|
||||
color: var(--subtext-0);
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@
|
|||
--font-size-lg: 1.0625rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-display: clamp(2.25rem, 5vw, 4.5rem);
|
||||
--shell-gutter: clamp(1rem, 3vw, 3rem);
|
||||
--radius: 1rem;
|
||||
--shell-gutter: clamp(0.9rem, 2vw, 2rem);
|
||||
--radius: 0.78rem;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background: var(--crust);
|
||||
|
|
|
|||
Loading…
Reference in a new issue