feat(web): implement analysis read models and workspace
This commit is contained in:
parent
451d0a36a0
commit
2bd68100e9
18 changed files with 1474 additions and 22 deletions
41
tui/include/trainlog/web_analysis.h
Normal file
41
tui/include/trainlog/web_analysis.h
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/* Trainlog Web Analyse V1 bounded Core read model. */
|
||||
#ifndef TRAINLOG_WEB_ANALYSIS_H
|
||||
#define TRAINLOG_WEB_ANALYSIS_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/status.h"
|
||||
|
||||
#define TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY (256U * 1024U)
|
||||
#define TRAINLOG_WEB_ANALYSIS_EXERCISES_MAX 128U
|
||||
#define TRAINLOG_WEB_ANALYSIS_POINTS_MAX 90U
|
||||
|
||||
typedef enum TrainlogWebAnalysisPeriod {
|
||||
TRAINLOG_WEB_ANALYSIS_7_DAYS = 7,
|
||||
TRAINLOG_WEB_ANALYSIS_30_DAYS = 30,
|
||||
TRAINLOG_WEB_ANALYSIS_90_DAYS = 90,
|
||||
TRAINLOG_WEB_ANALYSIS_ALL = 0
|
||||
} TrainlogWebAnalysisPeriod;
|
||||
|
||||
typedef struct TrainlogWebAnalysisQuery {
|
||||
TrainlogWebAnalysisPeriod period;
|
||||
int64_t reference_unix_second;
|
||||
const char *exercise_id;
|
||||
const char *measurement_metric;
|
||||
} TrainlogWebAnalysisQuery;
|
||||
|
||||
/* WHY: Dashboard and Analyse must not independently reinterpret history.
|
||||
* CONTRACT: the Core filters by the inclusive UTC interval and returns one
|
||||
* bounded, typed JSON projection; absent observations are JSON null, never
|
||||
* fabricated zeroes. exercise_id and metric are borrowed for this call.
|
||||
* INVARIANT: MAX values come only from max_results and BODY ZONE counts are
|
||||
* recorded primary-zone exposures, not physiological percentages. */
|
||||
TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
char *output,
|
||||
size_t capacity,
|
||||
size_t *output_size);
|
||||
|
||||
#endif
|
||||
|
|
@ -47,6 +47,9 @@ if web_assets_enabled
|
|||
'../web/src/vite-env.d.ts',
|
||||
'../web/src/api/health.ts',
|
||||
'../web/src/api/dashboard.ts',
|
||||
'../web/src/api/analysis.ts',
|
||||
'../web/src/api/analysis.test.ts',
|
||||
'../web/src/analysis/messages.ts',
|
||||
'../web/src/api/dashboardLayout.ts',
|
||||
'../web/src/api/preparedItems.ts',
|
||||
'../web/src/api/sync.ts',
|
||||
|
|
@ -73,6 +76,8 @@ if web_assets_enabled
|
|||
'../web/src/presentation/DatePreferences.tsx',
|
||||
'../web/src/presentation/dateFormat.ts',
|
||||
'../web/src/routes/DashboardPage.tsx',
|
||||
'../web/src/routes/AnalysisPage.tsx',
|
||||
'../web/src/routes/AnalysisPage.test.tsx',
|
||||
'../web/src/routes/PlaceholderPage.tsx',
|
||||
'../web/src/routes/SessionsPage.tsx',
|
||||
'../web/src/api/sessions.ts',
|
||||
|
|
@ -208,6 +213,7 @@ trainlog_core_sources = files(
|
|||
'src/json_writer.c',
|
||||
'src/web_dashboard.c',
|
||||
'src/web_dashboard_json.c',
|
||||
'src/web_analysis.c',
|
||||
'src/web_prepared_items.c',
|
||||
'src/web_prepared_items_json.c',
|
||||
'src/web_sessions.c',
|
||||
|
|
@ -360,6 +366,15 @@ test_web_dashboard = executable(
|
|||
)
|
||||
test('web_dashboard', test_web_dashboard)
|
||||
|
||||
test_web_analysis = executable(
|
||||
'test_web_analysis',
|
||||
'tests/test_web_analysis.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
include_directories: trainlog_private_include,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('web_analysis', test_web_analysis)
|
||||
|
||||
test_web_prepared_items = executable(
|
||||
'test_web_prepared_items',
|
||||
'tests/test_web_prepared_items.c',
|
||||
|
|
|
|||
599
tui/src/web_analysis.c
Normal file
599
tui/src/web_analysis.c
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
#include "trainlog/web_analysis.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/body_zone_catalog.h"
|
||||
|
||||
#define SECONDS_PER_DAY INT64_C(86400)
|
||||
|
||||
typedef struct MeasurementDefinition {
|
||||
const char *id;
|
||||
const char *column;
|
||||
const char *unit;
|
||||
} MeasurementDefinition;
|
||||
|
||||
static const MeasurementDefinition MEASUREMENTS[] = {
|
||||
{"weight", "body_weight_kg", "kg"},
|
||||
{"neck", "neck_cm", "cm"},
|
||||
{"shoulders", "shoulders_cm", "cm"},
|
||||
{"chest", "chest_cm", "cm"},
|
||||
{"waist", "waist_cm", "cm"},
|
||||
{"hips", "hips_cm", "cm"},
|
||||
{"left_arm", "left_arm_cm", "cm"},
|
||||
{"right_arm", "right_arm_cm", "cm"},
|
||||
{"left_forearm", "left_forearm_cm", "cm"},
|
||||
{"right_forearm", "right_forearm_cm", "cm"},
|
||||
{"left_thigh", "left_thigh_cm", "cm"},
|
||||
{"right_thigh", "right_thigh_cm", "cm"},
|
||||
{"left_calf", "left_calf_cm", "cm"},
|
||||
{"right_calf", "right_calf_cm", "cm"},
|
||||
};
|
||||
|
||||
static const MeasurementDefinition *measurement_definition(const char *id) {
|
||||
size_t index;
|
||||
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
|
||||
if (strcmp(id, MEASUREMENTS[index].id) == 0) {
|
||||
return &MEASUREMENTS[index];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool prepare(TrainlogDatabase *database, const char *sql, sqlite3_stmt **statement) {
|
||||
return sqlite3_prepare_v2(database->connection, sql, -1, statement, NULL) == SQLITE_OK;
|
||||
}
|
||||
|
||||
static bool bind_interval(sqlite3_stmt *statement, const TrainlogWebAnalysisQuery *query) {
|
||||
if (query->period == TRAINLOG_WEB_ANALYSIS_ALL) {
|
||||
if (sqlite3_bind_null(statement, 1) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
int64_t days = (int64_t)query->period;
|
||||
int64_t start = query->reference_unix_second - ((days - 1) * SECONDS_PER_DAY);
|
||||
if (sqlite3_bind_int64(statement, 1, start) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sqlite3_bind_int64(statement, 2, query->reference_unix_second) == SQLITE_OK;
|
||||
}
|
||||
|
||||
static bool add_nullable_real(yyjson_mut_doc *document,
|
||||
yyjson_mut_val *object,
|
||||
const char *key,
|
||||
sqlite3_stmt *statement,
|
||||
int column) {
|
||||
if (sqlite3_column_type(statement, column) == SQLITE_NULL) {
|
||||
return yyjson_mut_obj_add_null(document, object, key);
|
||||
}
|
||||
return yyjson_mut_obj_add_real(document, object, key, sqlite3_column_double(statement, column));
|
||||
}
|
||||
|
||||
static bool add_overview(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root) {
|
||||
static const char SQL[] =
|
||||
"WITH eligible AS (SELECT s.id,s.started_at,s.ended_at FROM sessions s WHERE "
|
||||
"(?1 IS NULL OR unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND "
|
||||
"(EXISTS(SELECT 1 FROM session_exercises x JOIN performed_sets p ON "
|
||||
"p.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM "
|
||||
"session_exercises x JOIN continuous_activity c ON c.session_exercise_row_id=x.id WHERE "
|
||||
"x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM session_exercises x JOIN max_results m ON "
|
||||
"m.session_exercise_row_id=x.id WHERE x.session_row_id=s.id))) SELECT COUNT(*),"
|
||||
"(SELECT COUNT(*) FROM performed_sets ps JOIN session_exercises se ON "
|
||||
"se.id=ps.session_exercise_row_id JOIN eligible e ON e.id=se.session_row_id),"
|
||||
"CASE WHEN COUNT(*)>0 AND COUNT(*)=SUM(CASE WHEN ended_at IS NOT NULL AND "
|
||||
"unixepoch(ended_at)>=unixepoch(started_at) THEN 1 ELSE 0 END) THEN "
|
||||
"SUM(unixepoch(ended_at)-unixepoch(started_at)) ELSE NULL END FROM eligible";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *overview = yyjson_mut_obj(document);
|
||||
bool ok;
|
||||
if (overview == NULL || !prepare(database, SQL, &statement) ||
|
||||
!bind_interval(statement, query) || sqlite3_step(statement) != SQLITE_ROW) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
ok = yyjson_mut_obj_add_sint(
|
||||
document, overview, "sessions", sqlite3_column_int64(statement, 0)) &&
|
||||
yyjson_mut_obj_add_sint(document, overview, "sets", sqlite3_column_int64(statement, 1)) &&
|
||||
(sqlite3_column_type(statement, 2) == SQLITE_NULL
|
||||
? yyjson_mut_obj_add_null(document, overview, "duration_seconds")
|
||||
: yyjson_mut_obj_add_sint(
|
||||
document, overview, "duration_seconds", sqlite3_column_int64(statement, 2))) &&
|
||||
yyjson_mut_obj_add_val(document, root, "overview", overview) &&
|
||||
sqlite3_step(statement) == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK;
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool add_activity(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root,
|
||||
bool *partial) {
|
||||
static const char SQL[] =
|
||||
"SELECT substr(s.started_at,1,10),COUNT(DISTINCT s.id),COUNT(DISTINCT ps.id) FROM "
|
||||
"sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id LEFT JOIN "
|
||||
"performed_sets ps ON ps.session_exercise_row_id=se.id WHERE (?1 IS NULL OR "
|
||||
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL OR "
|
||||
"EXISTS(SELECT 1 FROM session_exercises x JOIN continuous_activity c ON "
|
||||
"c.session_exercise_row_id=x.id WHERE x.session_row_id=s.id) OR EXISTS(SELECT 1 FROM "
|
||||
"session_exercises x JOIN max_results m ON m.session_exercise_row_id=x.id WHERE "
|
||||
"x.session_row_id=s.id)) GROUP BY substr(s.started_at,1,10) ORDER BY 1 DESC LIMIT ?3";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *activity = yyjson_mut_arr(document);
|
||||
size_t count = 0U;
|
||||
int step;
|
||||
if (activity == NULL || !prepare(database, SQL, &statement) ||
|
||||
!bind_interval(statement, query) ||
|
||||
sqlite3_bind_int64(statement, 3, (sqlite3_int64)(TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U)) !=
|
||||
SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *day;
|
||||
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
|
||||
*partial = true;
|
||||
continue;
|
||||
}
|
||||
day = yyjson_mut_obj(document);
|
||||
if (day == NULL ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, day, "date", (const char *)sqlite3_column_text(statement, 0)) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, day, "sessions", sqlite3_column_int64(statement, 1)) ||
|
||||
!yyjson_mut_obj_add_sint(document, day, "sets", sqlite3_column_int64(statement, 2)) ||
|
||||
!yyjson_mut_arr_add_val(activity, day)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
|
||||
yyjson_mut_obj_add_val(document, root, "activity", activity);
|
||||
}
|
||||
|
||||
static bool add_exercises(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root,
|
||||
char selected_id[64],
|
||||
bool *partial) {
|
||||
static const char LIST_SQL[] =
|
||||
"SELECT e.exercise_id,e.name,e.tracking_mode,e.recording_mode FROM exercises e ORDER BY "
|
||||
"e.name COLLATE NOCASE,e.exercise_id LIMIT ?1";
|
||||
static const char RECENT_SQL[] =
|
||||
"SELECT e.exercise_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id "
|
||||
"JOIN exercises e ON e.id=se.exercise_row_id WHERE (?1 IS NULL OR "
|
||||
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND "
|
||||
"(EXISTS(SELECT 1 FROM performed_sets p WHERE p.session_exercise_row_id=se.id) OR "
|
||||
"EXISTS(SELECT 1 FROM continuous_activity c WHERE c.session_exercise_row_id=se.id) OR "
|
||||
"EXISTS(SELECT 1 FROM max_results m WHERE m.session_exercise_row_id=se.id)) ORDER BY "
|
||||
"unixepoch(s.started_at) DESC,s.session_id DESC LIMIT 1";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *items = yyjson_mut_arr(document);
|
||||
size_t count = 0U;
|
||||
int step;
|
||||
if (query->exercise_id != NULL) {
|
||||
(void)snprintf(selected_id, 64U, "%s", query->exercise_id);
|
||||
} else if (prepare(database, RECENT_SQL, &statement) && bind_interval(statement, query)) {
|
||||
if (sqlite3_step(statement) == SQLITE_ROW) {
|
||||
(void)snprintf(selected_id, 64U, "%s", (const char *)sqlite3_column_text(statement, 0));
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
statement = NULL;
|
||||
} else {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (items == NULL || !prepare(database, LIST_SQL, &statement) ||
|
||||
sqlite3_bind_int64(statement, 1, TRAINLOG_WEB_ANALYSIS_EXERCISES_MAX + 1U) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *item;
|
||||
if (count == TRAINLOG_WEB_ANALYSIS_EXERCISES_MAX) {
|
||||
*partial = true;
|
||||
continue;
|
||||
}
|
||||
item = yyjson_mut_obj(document);
|
||||
if (item == NULL ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, item, "exercise_id", (const char *)sqlite3_column_text(statement, 0)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, item, "name", (const char *)sqlite3_column_text(statement, 1)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, item, "tracking_mode", (const char *)sqlite3_column_text(statement, 2)) ||
|
||||
!yyjson_mut_obj_add_strcpy(document,
|
||||
item,
|
||||
"recording_mode",
|
||||
(const char *)sqlite3_column_text(statement, 3)) ||
|
||||
!yyjson_mut_arr_add_val(items, item)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
|
||||
yyjson_mut_obj_add_val(document, root, "exercises", items);
|
||||
}
|
||||
|
||||
static bool add_exercise_detail(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
const char *exercise_id,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root,
|
||||
bool *partial) {
|
||||
static const char PROFILE_SQL[] =
|
||||
"SELECT name,tracking_mode,recording_mode FROM exercises WHERE exercise_id=?1";
|
||||
static const char POINT_SQL[] =
|
||||
"SELECT s.session_id,s.started_at,se.load_mode,COUNT(ps.id),SUM(ps.reps),"
|
||||
"SUM(ps.duration_seconds),CASE WHEN se.load_mode='external' THEN AVG(ps.weight_kg) ELSE "
|
||||
"NULL END,CASE WHEN se.load_mode='external' AND COUNT(ps.id)>0 AND "
|
||||
"COUNT(ps.id)=COUNT(ps.weight_kg) THEN SUM(ps.reps*ps.weight_kg) ELSE NULL END,"
|
||||
"SUM(c.duration_seconds),SUM(c.distance_km),AVG(c.speed_kmh),m.max_weight_kg FROM "
|
||||
"session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON "
|
||||
"e.id=se.exercise_row_id LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
|
||||
"LEFT JOIN continuous_activity c ON c.session_exercise_row_id=se.id LEFT JOIN max_results "
|
||||
"m ON m.session_exercise_row_id=se.id WHERE e.exercise_id=?3 AND (?1 IS NULL OR "
|
||||
"unixepoch(s.started_at)>=?1) AND unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL OR "
|
||||
"c.id IS NOT NULL OR m.session_exercise_row_id IS NOT NULL) GROUP BY se.id ORDER BY "
|
||||
"unixepoch(s.started_at) DESC,s.session_id DESC LIMIT ?4";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *detail = yyjson_mut_obj(document);
|
||||
yyjson_mut_val *points = yyjson_mut_arr(document);
|
||||
size_t count = 0U;
|
||||
int step;
|
||||
if (detail == NULL || points == NULL) {
|
||||
return false;
|
||||
}
|
||||
if (exercise_id[0] == '\0') {
|
||||
return yyjson_mut_obj_add_null(document, root, "exercise");
|
||||
}
|
||||
if (!prepare(database, PROFILE_SQL, &statement) ||
|
||||
sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
step = sqlite3_step(statement);
|
||||
if (step == SQLITE_DONE) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return yyjson_mut_obj_add_null(document, root, "exercise");
|
||||
}
|
||||
if (step != SQLITE_ROW ||
|
||||
!yyjson_mut_obj_add_strcpy(document, detail, "exercise_id", exercise_id) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, detail, "name", (const char *)sqlite3_column_text(statement, 0)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, detail, "tracking_mode", (const char *)sqlite3_column_text(statement, 1)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, detail, "recording_mode", (const char *)sqlite3_column_text(statement, 2)) ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
statement = NULL;
|
||||
if (!prepare(database, POINT_SQL, &statement) || !bind_interval(statement, query) ||
|
||||
sqlite3_bind_text(statement, 3, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK ||
|
||||
sqlite3_bind_int64(statement, 4, TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *point;
|
||||
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
|
||||
*partial = true;
|
||||
continue;
|
||||
}
|
||||
point = yyjson_mut_obj(document);
|
||||
if (point == NULL ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, point, "session_id", (const char *)sqlite3_column_text(statement, 0)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, point, "timestamp", (const char *)sqlite3_column_text(statement, 1)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, point, "load_mode", (const char *)sqlite3_column_text(statement, 2)) ||
|
||||
!yyjson_mut_obj_add_sint(document, point, "sets", sqlite3_column_int64(statement, 3)) ||
|
||||
!add_nullable_real(document, point, "reps", statement, 4) ||
|
||||
!add_nullable_real(document, point, "set_duration_seconds", statement, 5) ||
|
||||
!add_nullable_real(document, point, "external_load_kg", statement, 6) ||
|
||||
!add_nullable_real(document, point, "external_volume_kg", statement, 7) ||
|
||||
!add_nullable_real(document, point, "continuous_duration_seconds", statement, 8) ||
|
||||
!add_nullable_real(document, point, "distance_km", statement, 9) ||
|
||||
!add_nullable_real(document, point, "speed_kmh", statement, 10) ||
|
||||
!add_nullable_real(document, point, "explicit_max_kg", statement, 11) ||
|
||||
!yyjson_mut_arr_add_val(points, point)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
|
||||
yyjson_mut_obj_add_val(document, detail, "points", points) &&
|
||||
yyjson_mut_obj_add_val(document, root, "exercise", detail);
|
||||
}
|
||||
|
||||
static bool add_zones(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root) {
|
||||
static const char SQL[] =
|
||||
"SELECT z.zone_id,COUNT(DISTINCT se.id),COUNT(DISTINCT ps.id) FROM sessions s JOIN "
|
||||
"session_exercises se ON se.session_row_id=s.id JOIN exercise_body_zones z ON "
|
||||
"z.exercise_row_id=se.exercise_row_id AND z.role='primary' LEFT JOIN performed_sets ps ON "
|
||||
"ps.session_exercise_row_id=se.id WHERE (?1 IS NULL OR unixepoch(s.started_at)>=?1) AND "
|
||||
"unixepoch(s.started_at)<=?2 AND (ps.id IS NOT NULL OR EXISTS(SELECT 1 FROM "
|
||||
"continuous_activity c WHERE c.session_exercise_row_id=se.id) OR EXISTS(SELECT 1 FROM "
|
||||
"max_results m WHERE m.session_exercise_row_id=se.id)) GROUP BY z.zone_id ORDER BY "
|
||||
"COUNT(DISTINCT se.id) DESC,z.zone_id";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *zones = yyjson_mut_arr(document);
|
||||
int step;
|
||||
if (zones == NULL || !prepare(database, SQL, &statement) || !bind_interval(statement, query)) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
const char *id = (const char *)sqlite3_column_text(statement, 0);
|
||||
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(id);
|
||||
yyjson_mut_val *item = yyjson_mut_obj(document);
|
||||
if (zone == NULL || item == NULL ||
|
||||
!yyjson_mut_obj_add_strcpy(document, item, "zone_id", id) ||
|
||||
!yyjson_mut_obj_add_strcpy(document, item, "label", zone->display_name) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, item, "exposures", sqlite3_column_int64(statement, 1)) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, item, "associated_sets", sqlite3_column_int64(statement, 2)) ||
|
||||
!yyjson_mut_arr_add_val(zones, item)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return step == SQLITE_DONE && sqlite3_finalize(statement) == SQLITE_OK &&
|
||||
yyjson_mut_obj_add_val(document, root, "body_zones", zones);
|
||||
}
|
||||
|
||||
static bool add_measurements(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
yyjson_mut_doc *document,
|
||||
yyjson_mut_val *root,
|
||||
bool *partial) {
|
||||
yyjson_mut_val *measurements = yyjson_mut_obj(document);
|
||||
yyjson_mut_val *summaries = yyjson_mut_arr(document);
|
||||
yyjson_mut_val *points = yyjson_mut_arr(document);
|
||||
const MeasurementDefinition *selected = measurement_definition(query->measurement_metric);
|
||||
size_t index;
|
||||
if (measurements == NULL || summaries == NULL || points == NULL || selected == NULL) {
|
||||
return false;
|
||||
}
|
||||
for (index = 0U; index < sizeof(MEASUREMENTS) / sizeof(MEASUREMENTS[0]); ++index) {
|
||||
char sql[512];
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *summary = yyjson_mut_obj(document);
|
||||
int written = snprintf(
|
||||
sql,
|
||||
sizeof(sql),
|
||||
"WITH filtered AS (SELECT observed_at,observation_id,%s AS value FROM "
|
||||
"body_observations WHERE %s IS NOT NULL AND (?1 IS NULL OR "
|
||||
"unixepoch(observed_at)>=?1) AND unixepoch(observed_at)<=?2) SELECT COUNT(*),"
|
||||
"(SELECT value FROM filtered ORDER BY unixepoch(observed_at),observation_id LIMIT 1),"
|
||||
"(SELECT value FROM filtered ORDER BY unixepoch(observed_at) DESC,observation_id DESC "
|
||||
"LIMIT 1) FROM filtered",
|
||||
MEASUREMENTS[index].column,
|
||||
MEASUREMENTS[index].column);
|
||||
if (written < 0 || (size_t)written >= sizeof(sql) || summary == NULL ||
|
||||
!prepare(database, sql, &statement) || !bind_interval(statement, query) ||
|
||||
sqlite3_step(statement) != SQLITE_ROW ||
|
||||
!yyjson_mut_obj_add_strcpy(document, summary, "metric", MEASUREMENTS[index].id) ||
|
||||
!yyjson_mut_obj_add_strcpy(document, summary, "unit", MEASUREMENTS[index].unit) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, summary, "count", sqlite3_column_int64(statement, 0)) ||
|
||||
!add_nullable_real(document, summary, "first", statement, 1) ||
|
||||
!add_nullable_real(document, summary, "last", statement, 2) ||
|
||||
!(sqlite3_column_int64(statement, 0) < 2
|
||||
? yyjson_mut_obj_add_null(document, summary, "delta")
|
||||
: yyjson_mut_obj_add_real(document,
|
||||
summary,
|
||||
"delta",
|
||||
sqlite3_column_double(statement, 2) -
|
||||
sqlite3_column_double(statement, 1))) ||
|
||||
!yyjson_mut_arr_add_val(summaries, summary) || sqlite3_step(statement) != SQLITE_DONE ||
|
||||
sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
{
|
||||
char sql[512];
|
||||
sqlite3_stmt *statement = NULL;
|
||||
size_t count = 0U;
|
||||
int step;
|
||||
int written = snprintf(sql,
|
||||
sizeof(sql),
|
||||
"SELECT observed_at,%s FROM body_observations WHERE %s IS NOT NULL "
|
||||
"AND (?1 IS NULL OR unixepoch(observed_at)>=?1) AND "
|
||||
"unixepoch(observed_at)<=?2 ORDER BY unixepoch(observed_at) DESC,"
|
||||
"observation_id DESC LIMIT ?3",
|
||||
selected->column,
|
||||
selected->column);
|
||||
if (written < 0 || (size_t)written >= sizeof(sql) || !prepare(database, sql, &statement) ||
|
||||
!bind_interval(statement, query) ||
|
||||
sqlite3_bind_int64(statement, 3, TRAINLOG_WEB_ANALYSIS_POINTS_MAX + 1U) != SQLITE_OK) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
while ((step = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
yyjson_mut_val *point;
|
||||
if (count == TRAINLOG_WEB_ANALYSIS_POINTS_MAX) {
|
||||
*partial = true;
|
||||
continue;
|
||||
}
|
||||
point = yyjson_mut_obj(document);
|
||||
if (point == NULL ||
|
||||
!yyjson_mut_obj_add_strcpy(document,
|
||||
point,
|
||||
"timestamp",
|
||||
(const char *)sqlite3_column_text(statement, 0)) ||
|
||||
!yyjson_mut_obj_add_real(
|
||||
document, point, "value", sqlite3_column_double(statement, 1)) ||
|
||||
!yyjson_mut_arr_add_val(points, point)) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return false;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return yyjson_mut_obj_add_val(document, measurements, "summaries", summaries) &&
|
||||
yyjson_mut_obj_add_strcpy(document, measurements, "selected_metric", selected->id) &&
|
||||
yyjson_mut_obj_add_strcpy(document, measurements, "unit", selected->unit) &&
|
||||
yyjson_mut_obj_add_val(document, measurements, "points", points) &&
|
||||
yyjson_mut_obj_add_val(document, root, "measurements", measurements);
|
||||
}
|
||||
|
||||
static bool
|
||||
add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) {
|
||||
static const char SQL[] =
|
||||
"SELECT p.program_id,p.title,COUNT(ps.program_session_id),"
|
||||
"SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END),"
|
||||
"(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON "
|
||||
"pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND "
|
||||
"COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY "
|
||||
"CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) "
|
||||
"FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN "
|
||||
"program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE "
|
||||
"p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at "
|
||||
"DESC "
|
||||
"LIMIT 1";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
yyjson_mut_val *program = yyjson_mut_obj(document);
|
||||
int step;
|
||||
if (program == NULL || !prepare(database, SQL, &statement)) {
|
||||
if (statement != NULL) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
step = sqlite3_step(statement);
|
||||
if (step == SQLITE_DONE) {
|
||||
(void)sqlite3_finalize(statement);
|
||||
return yyjson_mut_obj_add_null(document, root, "active_program");
|
||||
}
|
||||
if (step != SQLITE_ROW ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, program, "program_id", (const char *)sqlite3_column_text(statement, 0)) ||
|
||||
!yyjson_mut_obj_add_strcpy(
|
||||
document, program, "title", (const char *)sqlite3_column_text(statement, 1)) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, program, "total_sessions", sqlite3_column_int64(statement, 2)) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, program, "completed_sessions", sqlite3_column_int64(statement, 3)) ||
|
||||
!(sqlite3_column_type(statement, 4) == SQLITE_NULL
|
||||
? yyjson_mut_obj_add_null(document, program, "next_session_title")
|
||||
: yyjson_mut_obj_add_strcpy(document,
|
||||
program,
|
||||
"next_session_title",
|
||||
(const char *)sqlite3_column_text(statement, 4))) ||
|
||||
!yyjson_mut_obj_add_val(document, root, "active_program", program) ||
|
||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_web_analysis_json(TrainlogDatabase *database,
|
||||
const TrainlogWebAnalysisQuery *query,
|
||||
char *output,
|
||||
size_t capacity,
|
||||
size_t *output_size) {
|
||||
yyjson_mut_doc *document;
|
||||
yyjson_mut_val *root;
|
||||
yyjson_mut_val *meta;
|
||||
char selected_id[64] = "";
|
||||
bool partial = false;
|
||||
char *json;
|
||||
size_t json_size;
|
||||
yyjson_write_err error;
|
||||
if (database == NULL || query == NULL || output == NULL || capacity == 0U ||
|
||||
output_size == NULL || query->reference_unix_second < 0 ||
|
||||
(query->period != TRAINLOG_WEB_ANALYSIS_7_DAYS &&
|
||||
query->period != TRAINLOG_WEB_ANALYSIS_30_DAYS &&
|
||||
query->period != TRAINLOG_WEB_ANALYSIS_90_DAYS &&
|
||||
query->period != TRAINLOG_WEB_ANALYSIS_ALL) ||
|
||||
query->measurement_metric == NULL ||
|
||||
measurement_definition(query->measurement_metric) == NULL ||
|
||||
(query->exercise_id != NULL && strlen(query->exercise_id) >= sizeof(selected_id))) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
*output_size = 0U;
|
||||
output[0] = '\0';
|
||||
document = yyjson_mut_doc_new(NULL);
|
||||
root = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
meta = document == NULL ? NULL : yyjson_mut_obj(document);
|
||||
if (document == NULL || root == NULL || meta == NULL) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
yyjson_mut_doc_set_root(document, root);
|
||||
if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
|
||||
!yyjson_mut_obj_add_strcpy(document,
|
||||
root,
|
||||
"period",
|
||||
query->period == TRAINLOG_WEB_ANALYSIS_ALL ? "all"
|
||||
: query->period == TRAINLOG_WEB_ANALYSIS_7_DAYS ? "7d"
|
||||
: query->period == TRAINLOG_WEB_ANALYSIS_30_DAYS ? "30d"
|
||||
: "90d") ||
|
||||
!add_overview(database, query, document, root) ||
|
||||
!add_activity(database, query, document, root, &partial) ||
|
||||
!add_exercises(database, query, document, root, selected_id, &partial) ||
|
||||
!add_exercise_detail(database, query, selected_id, document, root, &partial) ||
|
||||
!add_zones(database, query, document, root) ||
|
||||
!add_measurements(database, query, document, root, &partial) ||
|
||||
!add_program(database, document, root) ||
|
||||
!yyjson_mut_obj_add_bool(document, meta, "partial", partial) ||
|
||||
!yyjson_mut_obj_add_sint(
|
||||
document, meta, "reference_unix_second", query->reference_unix_second) ||
|
||||
!yyjson_mut_obj_add_val(document, root, "meta", meta)) {
|
||||
yyjson_mut_doc_free(document);
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, &json_size, &error);
|
||||
yyjson_mut_doc_free(document);
|
||||
if (json == NULL || json_size + 2U > capacity) {
|
||||
free(json);
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
(void)memcpy(output, json, json_size);
|
||||
output[json_size] = '\n';
|
||||
output[json_size + 1U] = '\0';
|
||||
*output_size = json_size + 1U;
|
||||
free(json);
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
#include <yyjson.h>
|
||||
|
||||
#include "trainlog/web_dashboard.h"
|
||||
#include "trainlog/web_analysis.h"
|
||||
#include "trainlog/web_exercises.h"
|
||||
#include "trainlog/web_prepared_items.h"
|
||||
#include "trainlog/web_programs.h"
|
||||
|
|
@ -64,6 +65,91 @@ static bool valid_sync_id(const char *value) {
|
|||
uuid_parse(value + 3U, (unsigned char[16]){0}) == 0;
|
||||
}
|
||||
|
||||
static bool valid_exercise_id(const char *value) {
|
||||
return value != NULL && strlen(value) == 39U && strncmp(value, "ex_", 3U) == 0 &&
|
||||
uuid_parse(value + 3U, (unsigned char[16]){0}) == 0;
|
||||
}
|
||||
|
||||
typedef struct TrainlogAnalysisArguments {
|
||||
bool valid;
|
||||
unsigned int period_count;
|
||||
unsigned int exercise_count;
|
||||
unsigned int metric_count;
|
||||
unsigned int page_count;
|
||||
} TrainlogAnalysisArguments;
|
||||
|
||||
static enum MHD_Result validate_analysis_argument(void *context,
|
||||
enum MHD_ValueKind kind,
|
||||
const char *key,
|
||||
const char *value) {
|
||||
TrainlogAnalysisArguments *arguments = context;
|
||||
unsigned int *count = NULL;
|
||||
(void)kind;
|
||||
(void)value;
|
||||
if (strcmp(key, "period") == 0) {
|
||||
count = &arguments->period_count;
|
||||
} else if (strcmp(key, "exercise_id") == 0) {
|
||||
count = &arguments->exercise_count;
|
||||
} else if (strcmp(key, "metric") == 0) {
|
||||
count = &arguments->metric_count;
|
||||
} else if (strcmp(key, "page") == 0) {
|
||||
count = &arguments->page_count;
|
||||
} else {
|
||||
arguments->valid = false;
|
||||
return MHD_NO;
|
||||
}
|
||||
++*count;
|
||||
if (*count > 1U) {
|
||||
arguments->valid = false;
|
||||
return MHD_NO;
|
||||
}
|
||||
return MHD_YES;
|
||||
}
|
||||
|
||||
static bool parse_analysis_period(const char *value, TrainlogWebAnalysisPeriod *period) {
|
||||
if (value == NULL || strcmp(value, "30d") == 0) {
|
||||
*period = TRAINLOG_WEB_ANALYSIS_30_DAYS;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(value, "7d") == 0) {
|
||||
*period = TRAINLOG_WEB_ANALYSIS_7_DAYS;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(value, "90d") == 0) {
|
||||
*period = TRAINLOG_WEB_ANALYSIS_90_DAYS;
|
||||
return true;
|
||||
}
|
||||
if (strcmp(value, "all") == 0) {
|
||||
*period = TRAINLOG_WEB_ANALYSIS_ALL;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool valid_measurement_metric(const char *value) {
|
||||
static const char *const VALUES[] = {"weight",
|
||||
"neck",
|
||||
"shoulders",
|
||||
"chest",
|
||||
"waist",
|
||||
"hips",
|
||||
"left_arm",
|
||||
"right_arm",
|
||||
"left_forearm",
|
||||
"right_forearm",
|
||||
"left_thigh",
|
||||
"right_thigh",
|
||||
"left_calf",
|
||||
"right_calf"};
|
||||
size_t index;
|
||||
for (index = 0U; index < sizeof(VALUES) / sizeof(VALUES[0]); ++index) {
|
||||
if (strcmp(value, VALUES[index]) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool trainlog_web_sync_accepted_serialize(const char *request_id,
|
||||
const char *run_id,
|
||||
char *output,
|
||||
|
|
@ -1263,6 +1349,55 @@ static enum MHD_Result handle_request(void *closure,
|
|||
context->csrf_token,
|
||||
"/api/v1/sync/status");
|
||||
}
|
||||
if (strcmp(url, "/api/v1/analysis") == 0) {
|
||||
TrainlogAnalysisArguments arguments = {.valid = true};
|
||||
TrainlogWebAnalysisQuery query;
|
||||
const char *period;
|
||||
const char *exercise_id;
|
||||
const char *metric;
|
||||
const char *page;
|
||||
char json[TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY];
|
||||
size_t json_size = 0U;
|
||||
time_t now;
|
||||
if (!is_get) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_METHOD_NOT_ALLOWED,
|
||||
"{\"error\":\"method_not_allowed\"}\n",
|
||||
"GET");
|
||||
}
|
||||
(void)MHD_get_connection_values(
|
||||
connection, MHD_GET_ARGUMENT_KIND, validate_analysis_argument, &arguments);
|
||||
period = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "period");
|
||||
exercise_id = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "exercise_id");
|
||||
metric = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "metric");
|
||||
page = MHD_lookup_connection_value(connection, MHD_GET_ARGUMENT_KIND, "page");
|
||||
if (!arguments.valid || !parse_analysis_period(period, &query.period) ||
|
||||
(exercise_id != NULL && !valid_exercise_id(exercise_id)) ||
|
||||
(metric != NULL && !valid_measurement_metric(metric)) ||
|
||||
(page != NULL && strcmp(page, "1") != 0)) {
|
||||
return queue_json(
|
||||
connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_analysis_query\"}\n", NULL);
|
||||
}
|
||||
now = time(NULL);
|
||||
if (now == (time_t)-1 || (int64_t)now != (int64_t)(time_t)now) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"clock_unavailable\"}\n",
|
||||
NULL);
|
||||
}
|
||||
query.reference_unix_second = (int64_t)now;
|
||||
query.exercise_id = exercise_id;
|
||||
query.measurement_metric = metric == NULL ? "weight" : metric;
|
||||
if (trainlog_web_analysis_json(context->database, &query, json, sizeof(json), &json_size) !=
|
||||
TRAINLOG_STATUS_OK ||
|
||||
json_size == 0U) {
|
||||
return queue_json(connection,
|
||||
MHD_HTTP_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"analysis_unavailable\"}\n",
|
||||
NULL);
|
||||
}
|
||||
return queue_json(connection, MHD_HTTP_OK, json, NULL);
|
||||
}
|
||||
if (strcmp(url, "/api/v1/dashboard") == 0) {
|
||||
TrainlogWebDashboardQuery query;
|
||||
TrainlogWebDashboardSnapshot snapshot;
|
||||
|
|
|
|||
238
tui/tests/test_web_analysis.c
Normal file
238
tui/tests/test_web_analysis.c
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <yyjson.h>
|
||||
|
||||
#include "database_internal.h"
|
||||
#include "trainlog/web_analysis.h"
|
||||
|
||||
#define CHECK(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
|
||||
return false; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool read_snapshot(TrainlogDatabase *database,
|
||||
TrainlogWebAnalysisPeriod period,
|
||||
const char *exercise_id,
|
||||
const char *metric,
|
||||
yyjson_doc **document) {
|
||||
TrainlogWebAnalysisQuery query = {
|
||||
.period = period,
|
||||
.reference_unix_second = INT64_C(1789560000),
|
||||
.exercise_id = exercise_id,
|
||||
.measurement_metric = metric,
|
||||
};
|
||||
char json[TRAINLOG_WEB_ANALYSIS_JSON_CAPACITY];
|
||||
size_t size = 0U;
|
||||
TrainlogStatus status;
|
||||
|
||||
status = trainlog_web_analysis_json(database, &query, json, sizeof(json), &size);
|
||||
if (status != TRAINLOG_STATUS_OK || size == 0U) {
|
||||
(void)fprintf(stderr, "analysis status=%d size=%zu\n", (int)status, size);
|
||||
return false;
|
||||
}
|
||||
*document = yyjson_read(json, size, 0U);
|
||||
return *document != NULL;
|
||||
}
|
||||
|
||||
static bool insert_session(TrainlogDatabase *database,
|
||||
const char *session_id,
|
||||
const char *entry_id,
|
||||
const char *exercise_id,
|
||||
const char *started_at,
|
||||
TrainlogTrackingMode tracking,
|
||||
TrainlogRecordingMode recording,
|
||||
TrainlogLoadMode load,
|
||||
bool explicit_max) {
|
||||
TrainlogSessionInput session;
|
||||
TrainlogSessionExerciseInput occurrence;
|
||||
TrainlogSetInput sets[2] = {
|
||||
{.reps = 8, .has_weight = load == TRAINLOG_LOAD_EXTERNAL, .weight_kg = 40.0},
|
||||
{.reps = 10, .has_weight = load == TRAINLOG_LOAD_EXTERNAL, .weight_kg = 42.0},
|
||||
};
|
||||
|
||||
(void)memset(&session, 0, sizeof(session));
|
||||
(void)memset(&occurrence, 0, sizeof(occurrence));
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
|
||||
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
|
||||
(void)snprintf(occurrence.entry_id, sizeof(occurrence.entry_id), "%s", entry_id);
|
||||
(void)snprintf(occurrence.exercise_id, sizeof(occurrence.exercise_id), "%s", exercise_id);
|
||||
session.session_type = explicit_max ? TRAINLOG_SESSION_MAX_TEST : TRAINLOG_SESSION_TRAINING;
|
||||
session.exercises = &occurrence;
|
||||
session.exercise_count = 1U;
|
||||
occurrence.tracking_mode = tracking;
|
||||
occurrence.recording_mode = recording;
|
||||
occurrence.load_mode = load;
|
||||
if (explicit_max) {
|
||||
occurrence.has_max_weight = true;
|
||||
occurrence.max_weight_kg = 100.0;
|
||||
} else if (recording == TRAINLOG_RECORDING_CONTINUOUS) {
|
||||
occurrence.continuous_duration_seconds = 1200;
|
||||
occurrence.continuous_distance_km = 3.0;
|
||||
occurrence.continuous_speed_kmh = 9.0;
|
||||
} else {
|
||||
occurrence.sets = sets;
|
||||
occurrence.set_count = 2U;
|
||||
}
|
||||
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static bool test_empty_and_periods(void) {
|
||||
static const TrainlogWebAnalysisPeriod PERIODS[] = {
|
||||
TRAINLOG_WEB_ANALYSIS_7_DAYS,
|
||||
TRAINLOG_WEB_ANALYSIS_30_DAYS,
|
||||
TRAINLOG_WEB_ANALYSIS_90_DAYS,
|
||||
TRAINLOG_WEB_ANALYSIS_ALL,
|
||||
};
|
||||
TrainlogDatabase *database = NULL;
|
||||
size_t index;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
for (index = 0U; index < sizeof(PERIODS) / sizeof(PERIODS[0]); ++index) {
|
||||
yyjson_doc *document = NULL;
|
||||
yyjson_val *root;
|
||||
CHECK(read_snapshot(database, PERIODS[index], NULL, "weight", &document));
|
||||
root = yyjson_doc_get_root(document);
|
||||
CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(root, "overview"), "sessions")) == 0);
|
||||
CHECK(yyjson_is_null(yyjson_obj_get(root, "exercise")));
|
||||
CHECK(yyjson_is_null(yyjson_obj_get(root, "active_program")));
|
||||
yyjson_doc_free(document);
|
||||
}
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_factual_models(void) {
|
||||
static const char LOADED[] = "ex_10000000-0000-4000-8000-000000000001";
|
||||
static const char UNLOADED[] = "ex_10000000-0000-4000-8000-000000000002";
|
||||
static const char DURATION[] = "ex_10000000-0000-4000-8000-000000000003";
|
||||
const char *none[] = {NULL};
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogBodyObservationInput observation;
|
||||
yyjson_doc *document = NULL;
|
||||
yyjson_val *root;
|
||||
yyjson_val *exercise;
|
||||
yyjson_val *measurements;
|
||||
yyjson_val *summaries;
|
||||
yyjson_val *waist;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||
LOADED,
|
||||
"Loaded",
|
||||
"loaded",
|
||||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
0) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||
UNLOADED,
|
||||
"Unloaded",
|
||||
"unloaded",
|
||||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
0) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(database,
|
||||
DURATION,
|
||||
"Duration",
|
||||
"duration",
|
||||
TRAINLOG_TRACKING_DURATION,
|
||||
TRAINLOG_RECORDING_CONTINUOUS,
|
||||
3) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(database, LOADED, "chest", none, 0U) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(database, UNLOADED, "arms", none, 0U) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(database, DURATION, "thighs", none, 0U) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(insert_session(database,
|
||||
"se_10000000-0000-4000-8000-000000000001",
|
||||
"en_10000000-0000-4000-8000-000000000001",
|
||||
LOADED,
|
||||
"2026-09-15T10:00:00Z",
|
||||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
TRAINLOG_LOAD_EXTERNAL,
|
||||
false));
|
||||
CHECK(insert_session(database,
|
||||
"se_10000000-0000-4000-8000-000000000002",
|
||||
"en_10000000-0000-4000-8000-000000000002",
|
||||
UNLOADED,
|
||||
"2026-09-14T10:00:00Z",
|
||||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
TRAINLOG_LOAD_NONE,
|
||||
false));
|
||||
CHECK(insert_session(database,
|
||||
"se_10000000-0000-4000-8000-000000000003",
|
||||
"en_10000000-0000-4000-8000-000000000003",
|
||||
DURATION,
|
||||
"2026-09-13T10:00:00Z",
|
||||
TRAINLOG_TRACKING_DURATION,
|
||||
TRAINLOG_RECORDING_CONTINUOUS,
|
||||
TRAINLOG_LOAD_NONE,
|
||||
false));
|
||||
CHECK(insert_session(database,
|
||||
"se_10000000-0000-4000-8000-000000000004",
|
||||
"en_10000000-0000-4000-8000-000000000004",
|
||||
LOADED,
|
||||
"2026-09-12T10:00:00Z",
|
||||
TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS,
|
||||
TRAINLOG_LOAD_EXTERNAL,
|
||||
true));
|
||||
|
||||
(void)memset(&observation, 0, sizeof(observation));
|
||||
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "bo_analysis_1");
|
||||
(void)snprintf(
|
||||
observation.observed_at, sizeof(observation.observed_at), "2026-09-10T08:00:00Z");
|
||||
observation.has_body_weight = true;
|
||||
observation.body_weight_kg = 80.0;
|
||||
observation.has_waist = true;
|
||||
observation.waist_cm = 90.0;
|
||||
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
|
||||
(void)snprintf(observation.observation_id, sizeof(observation.observation_id), "bo_analysis_2");
|
||||
(void)snprintf(
|
||||
observation.observed_at, sizeof(observation.observed_at), "2026-09-15T08:00:00Z");
|
||||
observation.body_weight_kg = 79.5;
|
||||
observation.waist_cm = 88.0;
|
||||
CHECK(trainlog_database_insert_body_observation(database, &observation) == TRAINLOG_STATUS_OK);
|
||||
|
||||
CHECK(
|
||||
sqlite3_exec(database->connection,
|
||||
"INSERT INTO programs VALUES('pg_analysis','Program',NULL,'active',NULL,NULL,"
|
||||
"'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1,"
|
||||
"'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',NULL);"
|
||||
"INSERT INTO program_sessions VALUES('ps_analysis','pg_analysis',0,'Next',"
|
||||
"'training','2026-09-21',NULL);",
|
||||
NULL,
|
||||
NULL,
|
||||
NULL) == SQLITE_OK);
|
||||
|
||||
CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, LOADED, "waist", &document));
|
||||
root = yyjson_doc_get_root(document);
|
||||
CHECK(yyjson_get_int(yyjson_obj_get(yyjson_obj_get(root, "overview"), "sessions")) == 4);
|
||||
exercise = yyjson_obj_get(root, "exercise");
|
||||
CHECK(strcmp(yyjson_get_str(yyjson_obj_get(exercise, "exercise_id")), LOADED) == 0);
|
||||
CHECK(yyjson_arr_size(yyjson_obj_get(exercise, "points")) == 2U);
|
||||
CHECK(yyjson_arr_size(yyjson_obj_get(root, "body_zones")) == 3U);
|
||||
CHECK(!yyjson_is_null(yyjson_obj_get(root, "active_program")));
|
||||
measurements = yyjson_obj_get(root, "measurements");
|
||||
CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U);
|
||||
summaries = yyjson_obj_get(measurements, "summaries");
|
||||
waist = yyjson_arr_get(summaries, 4U);
|
||||
CHECK(yyjson_get_int(yyjson_obj_get(waist, "count")) == 2);
|
||||
CHECK(yyjson_get_real(yyjson_obj_get(waist, "delta")) == -2.0);
|
||||
yyjson_doc_free(document);
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return test_empty_and_periods() && test_factual_models() ? 0 : 1;
|
||||
}
|
||||
|
|
@ -308,7 +308,7 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
|||
CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL);
|
||||
CHECK(strstr(response,
|
||||
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
|
||||
"\"version\":\"0.1.3\"}") != NULL);
|
||||
"\"version\":\"0.1.4\"}") != NULL);
|
||||
{
|
||||
char token[80];
|
||||
CHECK(exchange(port,
|
||||
|
|
@ -508,6 +508,26 @@ static bool test_http_contract(TrainlogDatabase *database) {
|
|||
CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL);
|
||||
CHECK(strstr(response, "\"window_days\":90") != NULL);
|
||||
CHECK(strstr(response, "\"partial\":false") != NULL);
|
||||
CHECK(exchange(port,
|
||||
"GET /api/v1/analysis?period=30d&metric=waist&page=1 HTTP/1.1\r\n"
|
||||
"Host: 127.0.0.1\r\n\r\n",
|
||||
response,
|
||||
sizeof(response)) &&
|
||||
strstr(response, "HTTP/1.1 200") != NULL &&
|
||||
strstr(response, "\"period\":\"30d\"") != NULL &&
|
||||
strstr(response, "\"selected_metric\":\"waist\"") != NULL);
|
||||
CHECK(exchange(port,
|
||||
"GET /api/v1/analysis?period=invalid HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||
response,
|
||||
sizeof(response)) &&
|
||||
strstr(response, "HTTP/1.1 400") != NULL &&
|
||||
strstr(response, "invalid_analysis_query") != NULL);
|
||||
CHECK(exchange(port,
|
||||
"GET /api/v1/analysis?period=7d&period=90d HTTP/1.1\r\n"
|
||||
"Host: 127.0.0.1\r\n\r\n",
|
||||
response,
|
||||
sizeof(response)) &&
|
||||
strstr(response, "HTTP/1.1 400") != NULL);
|
||||
CHECK(exchange(port,
|
||||
"GET /api/v1/prepared-items HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
|
||||
response,
|
||||
|
|
|
|||
28
web/src/analysis/messages.ts
Normal file
28
web/src/analysis/messages.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export type AnalysisLanguage = 'fr' | 'en'
|
||||
|
||||
export const analysisMessages = {
|
||||
fr: {
|
||||
eyebrow: 'DONNÉES FACTUELLES', title: 'Analyse', loading: 'Chargement de l’analyse…', failed: 'Analyse momentanément indisponible.',
|
||||
overview: 'Vue d’ensemble', exercises: 'Exercices', distribution: 'Répartition', measurements: 'Mensurations',
|
||||
period: 'Période', periods: { '7d': '7 jours', '30d': '30 jours', '90d': '90 jours', all: 'Tout' },
|
||||
sessions: 'Séances', sets: 'Séries', duration: 'Durée', unavailable: 'Indisponible', noData: 'Aucune donnée sur cette période.',
|
||||
activeProgram: 'Programme actif', noProgram: 'Aucun programme actif', nextSession: 'Prochaine séance',
|
||||
exercise: 'Exercice', noExercise: 'Aucun exercice disponible', noExerciseData: 'Aucune donnée compatible pour cet exercice.',
|
||||
reps: 'Répétitions', volume: 'Volume externe', max: 'MAX explicite', frequency: 'Fréquence', distance: 'Distance', speed: 'Vitesse',
|
||||
exposures: 'Expositions', zonesHelp: 'Occurrences enregistrées associées à une zone primaire. Ce ne sont pas des pourcentages physiologiques.',
|
||||
metric: 'Mesure', first: 'Première', last: 'Dernière', delta: 'Évolution', onePoint: 'Une seule mesure : aucune évolution calculée.',
|
||||
language: 'Langue', partial: 'Affichage borné aux observations les plus récentes.', programProgress: 'Progression du programme',
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'FACTUAL DATA', title: 'Analysis', loading: 'Loading analysis…', failed: 'Analysis is temporarily unavailable.',
|
||||
overview: 'Overview', exercises: 'Exercises', distribution: 'Distribution', measurements: 'Measurements',
|
||||
period: 'Period', periods: { '7d': '7 days', '30d': '30 days', '90d': '90 days', all: 'All' },
|
||||
sessions: 'Sessions', sets: 'Sets', duration: 'Duration', unavailable: 'Unavailable', noData: 'No data in this period.',
|
||||
activeProgram: 'Active program', noProgram: 'No active program', nextSession: 'Next session',
|
||||
exercise: 'Exercise', noExercise: 'No exercise available', noExerciseData: 'No compatible data for this exercise.',
|
||||
reps: 'Repetitions', volume: 'External volume', max: 'Explicit MAX', frequency: 'Frequency', distance: 'Distance', speed: 'Speed',
|
||||
exposures: 'Exposures', zonesHelp: 'Recorded occurrences associated with a primary zone. These are not physiological percentages.',
|
||||
metric: 'Measurement', first: 'First', last: 'Last', delta: 'Change', onePoint: 'Only one measurement: no change calculated.',
|
||||
language: 'Language', partial: 'Display is bounded to the most recent observations.', programProgress: 'Program progress',
|
||||
},
|
||||
} as const
|
||||
57
web/src/api/analysis.test.ts
Normal file
57
web/src/api/analysis.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchAnalysis, parseAnalysis, type AnalysisSnapshot } from './analysis'
|
||||
|
||||
export const analysisFixture: AnalysisSnapshot = {
|
||||
api_version: 1,
|
||||
period: '30d',
|
||||
overview: { sessions: 2, sets: 4, duration_seconds: null },
|
||||
activity: [{ date: '2026-09-20', sessions: 2, sets: 4 }],
|
||||
exercises: [{ exercise_id: 'ex_10000000-0000-4000-8000-000000000001', name: 'Presse', tracking_mode: 'reps', recording_mode: 'sets' }],
|
||||
exercise: {
|
||||
exercise_id: 'ex_10000000-0000-4000-8000-000000000001', name: 'Presse', tracking_mode: 'reps', recording_mode: 'sets',
|
||||
points: [{ session_id: 'se_10000000-0000-4000-8000-000000000001', timestamp: '2026-09-20T08:00:00Z', load_mode: 'external', sets: 2, reps: 18, set_duration_seconds: null, external_load_kg: 42, external_volume_kg: 756, continuous_duration_seconds: null, distance_km: null, speed_kmh: null, explicit_max_kg: 100 }],
|
||||
},
|
||||
body_zones: [{ zone_id: 'thighs', label: 'Cuisses', exposures: 1, associated_sets: 2 }],
|
||||
measurements: {
|
||||
selected_metric: 'waist', unit: 'cm', points: [{ timestamp: '2026-09-20T08:00:00Z', value: 88 }],
|
||||
summaries: [
|
||||
{ metric: 'weight', unit: 'kg', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'neck', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'shoulders', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'chest', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'waist', unit: 'cm', count: 1, first: 88, last: 88, delta: null },
|
||||
{ metric: 'hips', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'left_arm', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'right_arm', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'left_forearm', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'right_forearm', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'left_thigh', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'right_thigh', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'left_calf', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
{ metric: 'right_calf', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
||||
],
|
||||
},
|
||||
active_program: { program_id: 'pg_test', title: 'Programme', total_sessions: 4, completed_sessions: 1, next_session_title: 'Séance B' },
|
||||
meta: { partial: false, reference_unix_second: 1789560000 },
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('analysis API', () => {
|
||||
it('accepts persisted nulls without inventing zeroes', () => {
|
||||
expect(parseAnalysis(analysisFixture).overview.duration_seconds).toBeNull()
|
||||
expect(parseAnalysis(analysisFixture).measurements.summaries[4].delta).toBeNull()
|
||||
})
|
||||
|
||||
it('sends validated bounded query parameters', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(analysisFixture), { status: 200 }))
|
||||
await fetchAnalysis({ period: '90d', exerciseId: analysisFixture.exercises[0].exercise_id, metric: 'waist' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('period=90d'), expect.anything())
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('page=1'), expect.anything())
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('metric=waist'), expect.anything())
|
||||
})
|
||||
|
||||
it('rejects unbounded arrays', () => {
|
||||
expect(() => parseAnalysis({ ...analysisFixture, activity: Array.from({ length: 91 }, () => analysisFixture.activity[0]) })).toThrow('analysis_activity_invalid')
|
||||
})
|
||||
})
|
||||
84
web/src/api/analysis.ts
Normal file
84
web/src/api/analysis.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
export type AnalysisPeriod = '7d' | '30d' | '90d' | 'all'
|
||||
export type MeasurementMetric = 'weight' | 'neck' | 'shoulders' | 'chest' | 'waist' | 'hips' |
|
||||
'left_arm' | 'right_arm' | 'left_forearm' | 'right_forearm' |
|
||||
'left_thigh' | 'right_thigh' | 'left_calf' | 'right_calf'
|
||||
|
||||
export const ANALYSIS_PERIODS: readonly AnalysisPeriod[] = ['7d', '30d', '90d', 'all']
|
||||
export const MEASUREMENT_METRICS: readonly MeasurementMetric[] = [
|
||||
'weight', 'neck', 'shoulders', 'chest', 'waist', 'hips', 'left_arm', 'right_arm',
|
||||
'left_forearm', 'right_forearm', 'left_thigh', 'right_thigh', 'left_calf', 'right_calf',
|
||||
]
|
||||
|
||||
export interface AnalysisActivityDay { date: string; sessions: number; sets: number }
|
||||
export interface AnalysisExerciseOption { exercise_id: string; name: string; tracking_mode: 'reps' | 'duration'; recording_mode: 'sets' | 'continuous' }
|
||||
export interface AnalysisExercisePoint {
|
||||
session_id: string; timestamp: string; load_mode: 'none' | 'external' | 'assistance'
|
||||
sets: number; reps: number | null; set_duration_seconds: number | null
|
||||
external_load_kg: number | null; external_volume_kg: number | null; continuous_duration_seconds: number | null
|
||||
distance_km: number | null; speed_kmh: number | null; explicit_max_kg: number | null
|
||||
}
|
||||
export interface AnalysisExercise extends AnalysisExerciseOption { points: AnalysisExercisePoint[] }
|
||||
export interface AnalysisZone { zone_id: string; label: string; exposures: number; associated_sets: number }
|
||||
export interface MeasurementSummary { metric: MeasurementMetric; unit: 'kg' | 'cm'; count: number; first: number | null; last: number | null; delta: number | null }
|
||||
export interface MeasurementPoint { timestamp: string; value: number }
|
||||
export interface ActiveProgram { program_id: string; title: string; total_sessions: number; completed_sessions: number; next_session_title: string | null }
|
||||
|
||||
export interface AnalysisSnapshot {
|
||||
api_version: 1
|
||||
period: AnalysisPeriod
|
||||
overview: { sessions: number; sets: number; duration_seconds: number | null }
|
||||
activity: AnalysisActivityDay[]
|
||||
exercises: AnalysisExerciseOption[]
|
||||
exercise: AnalysisExercise | null
|
||||
body_zones: AnalysisZone[]
|
||||
measurements: { summaries: MeasurementSummary[]; selected_metric: MeasurementMetric; unit: 'kg' | 'cm'; points: MeasurementPoint[] }
|
||||
active_program: ActiveProgram | null
|
||||
meta: { partial: boolean; reference_unix_second: number }
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
||||
const count = (value: unknown): value is number => finite(value) && Number.isSafeInteger(value) && value >= 0
|
||||
const nullableFinite = (value: unknown): value is number | null => value === null || finite(value)
|
||||
const period = (value: unknown): value is AnalysisPeriod => typeof value === 'string' && ANALYSIS_PERIODS.includes(value as AnalysisPeriod)
|
||||
const metric = (value: unknown): value is MeasurementMetric => typeof value === 'string' && MEASUREMENT_METRICS.includes(value as MeasurementMetric)
|
||||
|
||||
/** CONTRACT: malformed or unbounded API shapes are rejected before rendering;
|
||||
* missing observations remain null and are never coerced to zero. */
|
||||
export function parseAnalysis(value: unknown): AnalysisSnapshot {
|
||||
if (!record(value) || value.api_version !== 1 || !period(value.period) || !record(value.overview) ||
|
||||
!Array.isArray(value.activity) || !Array.isArray(value.exercises) || !Array.isArray(value.body_zones) ||
|
||||
!record(value.measurements) || !record(value.meta)) throw new TypeError('analysis_invalid')
|
||||
if (!count(value.overview.sessions) || !count(value.overview.sets) || !nullableFinite(value.overview.duration_seconds)) throw new TypeError('analysis_overview_invalid')
|
||||
if (value.activity.length > 90 || !value.activity.every((item) => record(item) && typeof item.date === 'string' && count(item.sessions) && count(item.sets))) throw new TypeError('analysis_activity_invalid')
|
||||
if (value.exercises.length > 128 || !value.exercises.every((item) => record(item) && typeof item.exercise_id === 'string' && typeof item.name === 'string' && (item.tracking_mode === 'reps' || item.tracking_mode === 'duration') && (item.recording_mode === 'sets' || item.recording_mode === 'continuous'))) throw new TypeError('analysis_exercises_invalid')
|
||||
if (value.exercise !== null) {
|
||||
if (!record(value.exercise) || typeof value.exercise.exercise_id !== 'string' || typeof value.exercise.name !== 'string' ||
|
||||
(value.exercise.tracking_mode !== 'reps' && value.exercise.tracking_mode !== 'duration') ||
|
||||
(value.exercise.recording_mode !== 'sets' && value.exercise.recording_mode !== 'continuous') ||
|
||||
!Array.isArray(value.exercise.points) || value.exercise.points.length > 90 ||
|
||||
!value.exercise.points.every((point) => record(point) && typeof point.session_id === 'string' && typeof point.timestamp === 'string' &&
|
||||
(point.load_mode === 'none' || point.load_mode === 'external' || point.load_mode === 'assistance') && count(point.sets) &&
|
||||
nullableFinite(point.reps) && nullableFinite(point.set_duration_seconds) && nullableFinite(point.external_load_kg) && nullableFinite(point.external_volume_kg) &&
|
||||
nullableFinite(point.continuous_duration_seconds) && nullableFinite(point.distance_km) && nullableFinite(point.speed_kmh) && nullableFinite(point.explicit_max_kg))) throw new TypeError('analysis_exercise_invalid')
|
||||
}
|
||||
if (!value.body_zones.every((item) => record(item) && typeof item.zone_id === 'string' && typeof item.label === 'string' && count(item.exposures) && count(item.associated_sets))) throw new TypeError('analysis_zones_invalid')
|
||||
const summaries = value.measurements.summaries
|
||||
const points = value.measurements.points
|
||||
if (!Array.isArray(summaries) || summaries.length !== MEASUREMENT_METRICS.length || !summaries.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') && count(item.count) && nullableFinite(item.first) && nullableFinite(item.last) && nullableFinite(item.delta)) ||
|
||||
!metric(value.measurements.selected_metric) || (value.measurements.unit !== 'kg' && value.measurements.unit !== 'cm') || !Array.isArray(points) || points.length > 90 || !points.every((item) => record(item) && typeof item.timestamp === 'string' && finite(item.value))) throw new TypeError('analysis_measurements_invalid')
|
||||
if (value.active_program !== null && (!record(value.active_program) || typeof value.active_program.program_id !== 'string' || typeof value.active_program.title !== 'string' || !count(value.active_program.total_sessions) || !count(value.active_program.completed_sessions) || !(value.active_program.next_session_title === null || typeof value.active_program.next_session_title === 'string'))) throw new TypeError('analysis_program_invalid')
|
||||
if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid')
|
||||
return value as unknown as AnalysisSnapshot
|
||||
}
|
||||
|
||||
export interface AnalysisQuery { period?: AnalysisPeriod; exerciseId?: string; metric?: MeasurementMetric }
|
||||
|
||||
export async function fetchAnalysis(query: AnalysisQuery = {}, signal?: AbortSignal): Promise<AnalysisSnapshot> {
|
||||
const parameters = new URLSearchParams({ period: query.period ?? '30d', page: '1' })
|
||||
if (query.exerciseId) parameters.set('exercise_id', query.exerciseId)
|
||||
if (query.metric) parameters.set('metric', query.metric)
|
||||
const response = await fetch(`/api/v1/analysis?${parameters}`, { headers: { Accept: 'application/json' }, signal })
|
||||
if (!response.ok) throw new Error(`analysis HTTP ${response.status}`)
|
||||
return parseAnalysis(await response.json())
|
||||
}
|
||||
|
|
@ -9,12 +9,13 @@ import { PlaceholderPage } from '../routes/PlaceholderPage'
|
|||
import { ProgramsCalendarPage } from '../routes/ProgramsCalendarPage'
|
||||
import { SessionsPage } from '../routes/SessionsPage'
|
||||
import { ExercisesPage } from '../routes/ExercisesPage'
|
||||
import { AnalysisPage } from '../routes/AnalysisPage'
|
||||
import type { RouteId } from './routes'
|
||||
import { useRoute } from './useRoute'
|
||||
import { DatePreferencesProvider } from '../presentation/DatePreferences'
|
||||
import { fetchAnalysis, type AnalysisSnapshot } from '../api/analysis'
|
||||
|
||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'programs' | 'exercises'>, [string, string, string]> = {
|
||||
analysis: ['Analyse', 'COMPRENDRE', 'Les analyses détaillées apparaîtront ici lorsque leurs read models Core seront disponibles.'],
|
||||
const placeholderContent: Record<Exclude<RouteId, 'dashboard' | 'analysis' | 'programs' | 'exercises'>, [string, string, string]> = {
|
||||
sessions: ['Séances', 'ORGANISER', 'Les séances préparées et terminées seront présentées sans confondre plan et réalisé.'],
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ function AppContent() {
|
|||
const [preparedItems, setPreparedItems] = useState<PreparedItemsSnapshot | null>(null)
|
||||
const [preparedItemsPending, setPreparedItemsPending] = useState(true)
|
||||
const [preparedItemsFailed, setPreparedItemsFailed] = useState(false)
|
||||
const [analysis, setAnalysis] = useState<AnalysisSnapshot | null>(null)
|
||||
const reloadDashboard = useCallback(() => {
|
||||
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
|
||||
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
|
||||
|
|
@ -62,6 +64,7 @@ function AppContent() {
|
|||
}).catch(() => {
|
||||
setPreparedItemsFailed(true)
|
||||
}).finally(() => setPreparedItemsPending(false))
|
||||
fetchAnalysis({}, controller.signal).then(setAnalysis).catch(() => setAnalysis(null))
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
|
|
@ -73,8 +76,10 @@ function AppContent() {
|
|||
preparedItems={preparedItems}
|
||||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
analysis={analysis}
|
||||
/>
|
||||
) : route.id === 'sessions' ? <SessionsPage /> :
|
||||
) : route.id === 'analysis' ? <AnalysisPage /> :
|
||||
route.id === 'sessions' ? <SessionsPage /> :
|
||||
route.id === 'programs' ? <ProgramsCalendarPage onNavigate={navigate} /> :
|
||||
route.id === 'exercises' ? <ExercisesPage path={route.path} onNavigate={navigate} /> :
|
||||
<PlaceholderPage title={placeholderContent[route.id][0]}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export const routes: readonly AppRoute[] = [
|
|||
]
|
||||
|
||||
export function routeFromPath(pathname: string): AppRoute {
|
||||
if (pathname === '/analyse' || pathname.startsWith('/analyse?')) {
|
||||
return routes[1]
|
||||
}
|
||||
if (pathname === '/seances' || pathname.startsWith('/seances/')) {
|
||||
return { ...routes[3], path: pathname }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ describe('grille interactive du Dashboard', () => {
|
|||
expect(screen.getByText('Identité comparable : reps · external')).toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowLeft', shiftKey: true })
|
||||
expect(screen.queryByText('Identité comparable : reps · external')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(1)
|
||||
fireEvent.keyDown(screen.getByRole('article', { name: 'Records / MAX, tuile modifiable' }), { key: 'ArrowDown', shiftKey: true })
|
||||
expect(screen.getAllByText(/^Exercice \d$/)).toHaveLength(3)
|
||||
expect(screen.getByRole('article', { name: 'Progression, tuile modifiable' })).toHaveAttribute('data-size', 'medium')
|
||||
fireEvent.keyDown(screen.getByRole('article', { name: 'Mensurations, tuile modifiable' }), { key: 'ArrowDown', shiftKey: true })
|
||||
expect(screen.getByRole('article', { name: 'Mensurations, tuile modifiable' })).toHaveAttribute('data-size', 'medium')
|
||||
})
|
||||
|
||||
it('signale discrètement loading, erreur globale et invalid_data', async () => {
|
||||
|
|
|
|||
|
|
@ -18,16 +18,18 @@ import {
|
|||
type TileId,
|
||||
type TileLayout,
|
||||
} from './dashboardLayout'
|
||||
import { ActivityTile, CardioTile, LastSessionTile, MaxRecordsTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
|
||||
import { ActivityTile, LastSessionTile, MuscleDistributionTile, NextSessionTile, ProgressionTile, type DashboardTileComponent } from './DashboardTiles'
|
||||
import { MeasurementsTile, ProgramTile } from './DashboardTiles'
|
||||
import type { AnalysisSnapshot } from '../api/analysis'
|
||||
|
||||
const dashboardTiles: Readonly<Record<TileId, { title: string, eyebrow: string, component: DashboardTileComponent }>> = {
|
||||
'next-session': { title: 'Prochaine séance', eyebrow: 'Planification', component: NextSessionTile },
|
||||
activity: { title: 'Activité', eyebrow: 'Régularité', component: ActivityTile },
|
||||
progression: { title: 'Progression', eyebrow: 'Évolution', component: ProgressionTile },
|
||||
'last-session': { title: 'Dernière séance', eyebrow: 'Historique', component: LastSessionTile },
|
||||
'max-records': { title: 'Records / MAX', eyebrow: 'Performances', component: MaxRecordsTile },
|
||||
'max-records': { title: 'Mensurations', eyebrow: 'Évolution', component: MeasurementsTile },
|
||||
'muscle-distribution': { title: 'Répartition musculaire', eyebrow: 'Zones', component: MuscleDistributionTile },
|
||||
'cardio-recovery': { title: 'Cardio / récupération', eyebrow: 'Physiologie', component: CardioTile },
|
||||
'cardio-recovery': { title: 'Programme actif', eyebrow: 'Planification', component: ProgramTile },
|
||||
}
|
||||
|
||||
function breakpoint(width: number): { columns: 12 | 6 | 1, rowHeight: number } {
|
||||
|
|
@ -43,6 +45,7 @@ interface DashboardGridProps {
|
|||
preparedItems?: PreparedItemsSnapshot | null
|
||||
preparedItemsPending?: boolean
|
||||
preparedItemsFailed?: boolean
|
||||
analysis?: AnalysisSnapshot | null
|
||||
}
|
||||
|
||||
export function DashboardGrid({
|
||||
|
|
@ -52,6 +55,7 @@ export function DashboardGrid({
|
|||
preparedItems = null,
|
||||
preparedItemsPending = false,
|
||||
preparedItemsFailed = false,
|
||||
analysis = null,
|
||||
}: DashboardGridProps) {
|
||||
const [layout, setLayout] = useState<TileLayout[]>(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT))
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
|
@ -186,7 +190,7 @@ export function DashboardGrid({
|
|||
const size = tileSize(dimensions)
|
||||
const available = id === 'next-session'
|
||||
? (preparedItems?.items.length ?? 0) > 0
|
||||
: dashboard !== null && tileAvailable(dashboard, id)
|
||||
: dashboard !== null && tileAvailable(dashboard, id, analysis)
|
||||
const tilePending = id === 'next-session' ? preparedItemsPending : pending
|
||||
const partial = dashboard?.meta.partial === true && id === 'max-records'
|
||||
const TileContent = content.component
|
||||
|
|
@ -211,7 +215,7 @@ export function DashboardGrid({
|
|||
) : dashboard === null ? (
|
||||
<div className="tile-data-placeholder" aria-hidden="true"><span /><span /><span /></div>
|
||||
) : (
|
||||
<TileContent snapshot={dashboard} size={size} />
|
||||
<TileContent snapshot={dashboard} size={size} analysis={analysis} />
|
||||
)}
|
||||
</Tile>
|
||||
</div>
|
||||
|
|
@ -223,12 +227,12 @@ export function DashboardGrid({
|
|||
)
|
||||
}
|
||||
|
||||
function tileAvailable(snapshot: DashboardSnapshot, id: TileId): boolean {
|
||||
function tileAvailable(snapshot: DashboardSnapshot, id: TileId, analysis: AnalysisSnapshot | null): boolean {
|
||||
if (id === 'next-session') return false
|
||||
if (id === 'activity') return snapshot.data.activity.available
|
||||
if (id === 'progression') return snapshot.data.progression.available
|
||||
if (id === 'last-session') return snapshot.data.last_session.available
|
||||
if (id === 'max-records') return snapshot.data.max_records.available
|
||||
if (id === 'max-records') return analysis?.measurements.summaries.some((item) => item.last !== null) === true
|
||||
if (id === 'muscle-distribution') return snapshot.data.muscle_distribution.available
|
||||
return snapshot.data.cardio.available
|
||||
return analysis?.active_program !== null && analysis?.active_program !== undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,11 @@ import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
|||
import type { PreparedItem } from '../api/preparedItems'
|
||||
import { useDatePreferences } from '../presentation/DatePreferences'
|
||||
import { validDateSortValue, validTimestampValue } from '../presentation/dateFormat'
|
||||
import type { AnalysisSnapshot } from '../api/analysis'
|
||||
|
||||
const ProgressionChart = lazy(() => import('../charts/ProgressionChart').then((module) => ({ default: module.ProgressionChart })))
|
||||
|
||||
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize }
|
||||
interface TileDataProps { snapshot: DashboardSnapshot; size: TileSize; analysis?: AnalysisSnapshot | null }
|
||||
|
||||
export interface NextSessionTileProps {
|
||||
size: TileSize
|
||||
|
|
@ -99,21 +100,22 @@ export function NextSessionTile({
|
|||
)}
|
||||
{failed && <small>Dernière lecture conservée · actualisation indisponible</small>}
|
||||
{pending && !failed && <small>Actualisation…</small>}
|
||||
<a className="tile-context-link" href={`/seances/${proposal ? 'proposal' : manual ? 'preparation' : 'draft'}/${encodeURIComponent(item.identity)}`}>Ouvrir la séance</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActivityTile({ snapshot, size }: TileDataProps) {
|
||||
export function ActivityTile({ snapshot, size, analysis }: TileDataProps) {
|
||||
const { dateFormat } = useDatePreferences()
|
||||
const days = snapshot.data.activity.days
|
||||
// CONTRACT: these are direct presentation totals of Core-projected counters,
|
||||
// never an activity or physiological-intensity score.
|
||||
const activeDays = days.filter((day) => day.active).length
|
||||
const sessions = days.reduce((total, day) => total + day.session_count, 0)
|
||||
const sets = days.reduce((total, day) => total + day.set_count, 0)
|
||||
const sessions = analysis?.overview.sessions ?? days.reduce((total, day) => total + day.session_count, 0)
|
||||
const sets = analysis?.overview.sets ?? days.reduce((total, day) => total + day.set_count, 0)
|
||||
const shown = size === 'compact' ? days.slice(-35) : days
|
||||
return <div className="activity-content">
|
||||
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}</div>
|
||||
<div className="metric-row"><Metric value={String(activeDays)} label="jours actifs" />{size !== 'compact' && <Metric value={String(sessions)} label="séances" />}{size === 'large' && <Metric value={String(sets)} label="séries" />}{size === 'large' && analysis?.overview.duration_seconds !== null && analysis?.overview.duration_seconds !== undefined && <Metric value={formatDuration(analysis.overview.duration_seconds)} label="durée calculable" />}</div>
|
||||
<div className="activity-heatmap" 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')}`} />)}
|
||||
</div>
|
||||
|
|
@ -121,8 +123,13 @@ export function ActivityTile({ snapshot, size }: TileDataProps) {
|
|||
</div>
|
||||
}
|
||||
|
||||
export function ProgressionTile({ snapshot, size }: TileDataProps) {
|
||||
export function ProgressionTile({ snapshot, size, analysis }: TileDataProps) {
|
||||
const { dateFormat } = useDatePreferences()
|
||||
if (analysis?.exercise !== null && analysis?.exercise !== undefined) {
|
||||
const exercise = analysis.exercise
|
||||
const latest = exercise.points[0]
|
||||
return <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"><Fact label="Séries" value={String(latest.sets)} />{latest.reps !== null && <Fact label="Répétitions" value={String(latest.reps)} />}{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>}<a className="tile-context-link" href={`/analyse?section=exercise&exercise_id=${encodeURIComponent(exercise.exercise_id)}&period=30d`}>Voir dans Analyse</a></div>
|
||||
}
|
||||
const progression = snapshot.data.progression
|
||||
if (!progression.available) return <Unavailable text="Aucune performance comparable disponible" />
|
||||
const first = progression.points[0]
|
||||
|
|
@ -167,7 +174,7 @@ export function MuscleDistributionTile({ snapshot, size }: TileDataProps) {
|
|||
const maximum = Math.max(...ordered.map((zone) => zone.session_count), 1)
|
||||
return <div className="muscle-content">
|
||||
{size !== 'compact' && <div className="muscle-visual"><BodyZoneFigure zones={zones} /><p className="chart-legend">Couleur : séances sur 30 jours · maximum relatif au snapshot affiché</p></div>}
|
||||
<div className="muscle-details"><p className="chart-legend">Longueur : nombre de séances sur 30 jours</p><ul className="muscle-list">{shown.map((zone) => <MuscleRow key={zone.zone_id} zone={zone} maximum={maximum} detailed={size !== 'compact'} full={size === 'large'} />)}</ul></div>
|
||||
<div className="muscle-details"><p className="chart-legend">Longueur : nombre de séances sur 30 jours</p><ul className="muscle-list">{shown.map((zone) => <MuscleRow key={zone.zone_id} zone={zone} maximum={maximum} detailed={size !== 'compact'} full={size === 'large'} />)}</ul><a className="tile-context-link" href="/analyse?section=body-zones&period=30d">Voir dans Analyse</a></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +186,19 @@ export function CardioTile({ size }: TileDataProps) {
|
|||
return <div className="unavailable-content"><strong aria-hidden="true">—</strong>{size !== 'compact' && <p>Aucune donnée cardio disponible</p>}{size === 'large' && <small>Les mesures apparaîtront ici lorsqu’une source cardio sera enregistrée.</small>}</div>
|
||||
}
|
||||
|
||||
export function ProgramTile({ size, analysis }: TileDataProps) {
|
||||
const program = analysis?.active_program
|
||||
if (program === null || program === undefined) return <Unavailable text="Aucun programme actif" />
|
||||
return <div className="program-progress"><strong>{program.title}</strong><div><span>{program.completed_sessions}/{program.total_sessions} séances effectuées</span></div><progress max={program.total_sessions || 1} value={program.completed_sessions} aria-label="Progression du programme actif" />{size !== 'compact' && <p>{program.next_session_title === null ? 'Programme terminé' : `Prochaine séance : ${program.next_session_title}`}</p>}<a className="tile-context-link" href="/programmes">Ouvrir Programmes</a></div>
|
||||
}
|
||||
|
||||
export function MeasurementsTile({ size, analysis }: TileDataProps) {
|
||||
const summaries = analysis?.measurements.summaries ?? []
|
||||
const visible = summaries.filter((summary) => summary.metric === 'weight' || summary.metric === 'waist').filter((summary) => summary.last !== null)
|
||||
if (visible.length === 0) return <Unavailable text="Aucune mensuration disponible" />
|
||||
return <div className="measurement-summary"><dl className="fact-list horizontal">{visible.map((summary) => <div key={summary.metric}><dt>{summary.metric === 'weight' ? 'Poids' : 'Tour de taille'}</dt><dd>{formatWeight(summary.last ?? 0).replace('kg', summary.unit)}{summary.delta !== null && size !== 'compact' && <small>{summary.delta > 0 ? '+' : ''}{summary.delta.toLocaleString('fr-FR', { maximumFractionDigits: 2 })} {summary.unit}</small>}</dd></div>)}</dl><a className="tile-context-link" href="/analyse?section=measurements&metric=waist&period=30d">Voir dans Analyse</a></div>
|
||||
}
|
||||
|
||||
function Metric({ value, label }: { value: string; label: string }) { return <div className="metric"><strong>{value}</strong><span>{label}</span></div> }
|
||||
function Fact({ label, value }: { label: string; value: ReactNode }) { return <div><dt>{label}</dt><dd>{value}</dd></div> }
|
||||
function Unavailable({ text }: { text: string }) { return <div className="unavailable-content"><strong aria-hidden="true">—</strong><p>{text}</p></div> }
|
||||
|
|
|
|||
35
web/src/routes/AnalysisPage.test.tsx
Normal file
35
web/src/routes/AnalysisPage.test.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AnalysisPage } from './AnalysisPage'
|
||||
import { analysisFixture } from '../api/analysis.test'
|
||||
|
||||
describe('AnalysisPage', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/analyse?section=invalid&period=broken&metric=unknown&exercise_id=nope')
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(analysisFixture), { status: 200 }))
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('falls back safely and changes sections, periods and metrics', async () => {
|
||||
render(<AnalysisPage />)
|
||||
expect(await screen.findByRole('heading', { name: 'Analyse' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Vue d’ensemble' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByLabelText('Période')).toHaveValue('30d')
|
||||
fireEvent.change(screen.getByLabelText('Période'), { target: { value: '7d' } })
|
||||
await waitFor(() => expect(globalThis.fetch).toHaveBeenLastCalledWith(expect.stringContaining('period=7d'), expect.anything()))
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Mensurations' }))
|
||||
fireEvent.change(screen.getByLabelText('Mesure'), { target: { value: 'waist' } })
|
||||
expect(window.location.search).toContain('section=measurements')
|
||||
})
|
||||
|
||||
it('supports exercise and BODY ZONE deep-link sections and English UI', async () => {
|
||||
window.history.replaceState(null, '', `/analyse?section=exercise&exercise_id=${analysisFixture.exercises[0].exercise_id}&period=30d`)
|
||||
render(<AnalysisPage />)
|
||||
expect(await screen.findByRole('heading', { name: 'Presse' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Répartition' }))
|
||||
expect(screen.getByText(/pourcentages physiologiques/)).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText('Langue'), { target: { value: 'en' } })
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/not physiological percentages/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
127
web/src/routes/AnalysisPage.tsx
Normal file
127
web/src/routes/AnalysisPage.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ANALYSIS_PERIODS, MEASUREMENT_METRICS, fetchAnalysis, type AnalysisExercisePoint, type AnalysisPeriod, type AnalysisSnapshot, type MeasurementMetric } from '../api/analysis'
|
||||
import { BodyZoneFigure } from '../dashboard/BodyZoneFigure'
|
||||
import { analysisMessages, type AnalysisLanguage } from '../analysis/messages'
|
||||
|
||||
type AnalysisSection = 'overview' | 'exercise' | 'body-zones' | 'measurements'
|
||||
const sections: readonly AnalysisSection[] = ['overview', 'exercise', 'body-zones', 'measurements']
|
||||
const exerciseIdPattern = /^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const metricLabels = {
|
||||
fr: { weight: 'Poids', neck: 'Cou', shoulders: 'Épaules', chest: 'Poitrine', waist: 'Taille', hips: 'Hanches', left_arm: 'Bras gauche', right_arm: 'Bras droit', left_forearm: 'Avant-bras gauche', right_forearm: 'Avant-bras droit', left_thigh: 'Cuisse gauche', right_thigh: 'Cuisse droite', left_calf: 'Mollet gauche', right_calf: 'Mollet droit' },
|
||||
en: { weight: 'Weight', neck: 'Neck', shoulders: 'Shoulders', chest: 'Chest', waist: 'Waist', hips: 'Hips', left_arm: 'Left arm', right_arm: 'Right arm', left_forearm: 'Left forearm', right_forearm: 'Right forearm', left_thigh: 'Left thigh', right_thigh: 'Right thigh', left_calf: 'Left calf', right_calf: 'Right calf' },
|
||||
} satisfies Record<AnalysisLanguage, Record<MeasurementMetric, string>>
|
||||
|
||||
function safeInitialQuery() {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
const sectionValue = query.get('section')
|
||||
const periodValue = query.get('period')
|
||||
const metricValue = query.get('metric')
|
||||
const exerciseValue = query.get('exercise_id')
|
||||
return {
|
||||
section: sections.includes(sectionValue as AnalysisSection) ? sectionValue as AnalysisSection : 'overview',
|
||||
period: ANALYSIS_PERIODS.includes(periodValue as AnalysisPeriod) ? periodValue as AnalysisPeriod : '30d',
|
||||
metric: MEASUREMENT_METRICS.includes(metricValue as MeasurementMetric) ? metricValue as MeasurementMetric : 'weight',
|
||||
exerciseId: exerciseValue !== null && exerciseIdPattern.test(exerciseValue) ? exerciseValue : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number, language: AnalysisLanguage, maximumFractionDigits = 2) {
|
||||
return new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : 'en-US', { maximumFractionDigits }).format(value)
|
||||
}
|
||||
|
||||
function duration(value: number, language: AnalysisLanguage) {
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
return hours > 0 ? `${hours} h ${minutes.toString().padStart(2, '0')} min` : `${minutes} min${language === 'en' ? '' : ''}`
|
||||
}
|
||||
|
||||
function MiniLineChart({ points, unit, language, label }: { points: Array<{ timestamp: string; value: number }>; unit: string; language: AnalysisLanguage; label: string }) {
|
||||
if (points.length === 0) return null
|
||||
const ordered = [...points].reverse()
|
||||
const values = ordered.map((point) => point.value)
|
||||
const minimum = Math.min(...values)
|
||||
const maximum = Math.max(...values)
|
||||
const span = maximum - minimum || 1
|
||||
const polyline = ordered.map((point, index) => `${ordered.length === 1 ? 50 : (index / (ordered.length - 1)) * 100},${36 - ((point.value - minimum) / span) * 30}`).join(' ')
|
||||
return <figure className="analysis-chart"><svg viewBox="0 0 100 40" role="img" aria-label={`${label}: ${points.length} points`} preserveAspectRatio="none"><polyline points={polyline} fill="none" vectorEffect="non-scaling-stroke" /><g>{ordered.map((point, index) => <circle key={`${point.timestamp}-${index}`} cx={ordered.length === 1 ? 50 : (index / (ordered.length - 1)) * 100} cy={36 - ((point.value - minimum) / span) * 30} r="1.5"><title>{new Intl.DateTimeFormat(language === 'fr' ? 'fr-FR' : 'en-US').format(new Date(point.timestamp))} · {formatNumber(point.value, language)} {unit}</title></circle>)}</g></svg><figcaption>{formatNumber(minimum, language)}–{formatNumber(maximum, language)} {unit} · {points.length} points · no interpolation</figcaption></figure>
|
||||
}
|
||||
|
||||
function ExerciseFacts({ points, language }: { points: AnalysisExercisePoint[]; language: AnalysisLanguage }) {
|
||||
const t = analysisMessages[language]
|
||||
if (points.length === 0) return <p className="analysis-empty">{t.noExerciseData}</p>
|
||||
const latest = points[0]
|
||||
return <>
|
||||
<dl className="analysis-facts">
|
||||
<Fact label={t.sets} value={latest.sets} language={language} />
|
||||
{latest.reps !== null && <Fact label={t.reps} value={latest.reps} language={language} />}
|
||||
{latest.external_load_kg !== null && <Fact label={language === 'fr' ? 'Charge' : 'Load'} value={latest.external_load_kg} unit="kg" language={language} />}
|
||||
{latest.external_volume_kg !== null && <Fact label={t.volume} value={latest.external_volume_kg} unit="kg" language={language} />}
|
||||
{(latest.set_duration_seconds ?? latest.continuous_duration_seconds) !== null && <Fact label={t.duration} value={latest.set_duration_seconds ?? latest.continuous_duration_seconds ?? 0} unit="s" language={language} />}
|
||||
{latest.distance_km !== null && <Fact label={t.distance} value={latest.distance_km} unit="km" language={language} />}
|
||||
{latest.speed_kmh !== null && <Fact label={t.speed} value={latest.speed_kmh} unit="km/h" language={language} />}
|
||||
{latest.explicit_max_kg !== null && <Fact label={t.max} value={latest.explicit_max_kg} unit="kg" language={language} />}
|
||||
{latest.load_mode === 'none' && latest.reps !== null && <Fact label={t.frequency} value={points.length} language={language} />}
|
||||
</dl>
|
||||
<MiniLineChart points={points.map((point) => ({ timestamp: point.timestamp, value: point.explicit_max_kg ?? point.external_volume_kg ?? point.reps ?? point.continuous_duration_seconds ?? point.set_duration_seconds ?? 0 })).filter((point) => point.value > 0)} unit={latest.explicit_max_kg !== null || latest.external_volume_kg !== null ? 'kg' : latest.reps !== null ? 'reps' : 's'} language={language} label={t.exercise} />
|
||||
</>
|
||||
}
|
||||
|
||||
function Fact({ label, value, unit, language }: { label: string; value: number; unit?: string; language: AnalysisLanguage }) {
|
||||
return <div><dt>{label}</dt><dd>{formatNumber(value, language)}{unit ? ` ${unit}` : ''}</dd></div>
|
||||
}
|
||||
|
||||
export function AnalysisPage() {
|
||||
const initial = useMemo(safeInitialQuery, [])
|
||||
const [section, setSection] = useState<AnalysisSection>(initial.section)
|
||||
const [period, setPeriod] = useState<AnalysisPeriod>(initial.period)
|
||||
const [metric, setMetric] = useState<MeasurementMetric>(initial.metric)
|
||||
const [exerciseId, setExerciseId] = useState<string | undefined>(initial.exerciseId)
|
||||
const [language, setLanguage] = useState<AnalysisLanguage>('fr')
|
||||
const [snapshot, setSnapshot] = useState<AnalysisSnapshot | null>(null)
|
||||
const [pending, setPending] = useState(true)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const t = analysisMessages[language]
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setPending(true)
|
||||
fetchAnalysis({ period, exerciseId, metric }, controller.signal).then((value) => {
|
||||
setSnapshot(value); setFailed(false)
|
||||
if (exerciseId === undefined && value.exercise !== null) setExerciseId(value.exercise.exercise_id)
|
||||
}).catch(() => { if (!controller.signal.aborted) setFailed(true) }).finally(() => { if (!controller.signal.aborted) setPending(false) })
|
||||
return () => controller.abort()
|
||||
}, [period, exerciseId, metric])
|
||||
|
||||
function updateUrl(nextSection = section, nextPeriod = period, nextExercise = exerciseId, nextMetric = metric) {
|
||||
const query = new URLSearchParams({ section: nextSection, period: nextPeriod })
|
||||
if (nextExercise) query.set('exercise_id', nextExercise)
|
||||
if (nextMetric !== 'weight' || nextSection === 'measurements') query.set('metric', nextMetric)
|
||||
window.history.replaceState(null, '', `/analyse?${query}`)
|
||||
}
|
||||
function chooseSection(value: AnalysisSection) { setSection(value); updateUrl(value) }
|
||||
function choosePeriod(value: AnalysisPeriod) { setPeriod(value); updateUrl(section, value) }
|
||||
function chooseExercise(value: string) { setExerciseId(value); updateUrl('exercise', period, value); setSection('exercise') }
|
||||
function chooseMetric(value: MeasurementMetric) { setMetric(value); updateUrl('measurements', period, exerciseId, value); setSection('measurements') }
|
||||
|
||||
const selectedSummary = snapshot?.measurements.summaries.find((item) => item.metric === metric)
|
||||
const zoneFigure = snapshot?.body_zones.map((zone) => ({ zone_id: zone.zone_id, label: zone.label, session_count: zone.exposures, occurrence_count: zone.exposures, set_count: zone.associated_sets })) ?? []
|
||||
return <section className="page analysis-page" aria-labelledby="page-title">
|
||||
<div className="page-heading"><div><p className="eyebrow">{t.eyebrow}</p><h1 id="page-title">{t.title}</h1></div><label className="analysis-language">{t.language}<select value={language} onChange={(event) => setLanguage(event.target.value as AnalysisLanguage)}><option value="fr">Français</option><option value="en">English</option></select></label></div>
|
||||
<div className="analysis-toolbar"><div className="analysis-tabs" role="tablist" aria-label={t.title}>{sections.map((id) => <button key={id} type="button" role="tab" aria-selected={section === id} onClick={() => chooseSection(id)}>{id === 'overview' ? t.overview : id === 'exercise' ? t.exercises : id === 'body-zones' ? t.distribution : t.measurements}</button>)}</div><label>{t.period}<select aria-label={t.period} value={period} onChange={(event) => choosePeriod(event.target.value as AnalysisPeriod)}>{ANALYSIS_PERIODS.map((value) => <option key={value} value={value}>{t.periods[value]}</option>)}</select></label></div>
|
||||
{pending && snapshot === null && <p role="status">{t.loading}</p>}
|
||||
{failed && snapshot === null && <p className="error-panel" role="alert">{t.failed}</p>}
|
||||
{snapshot !== null && <>
|
||||
{snapshot.meta.partial && <p className="analysis-note">{t.partial}</p>}
|
||||
{section === 'overview' && <div className="analysis-section-grid">
|
||||
<article className="analysis-panel"><h2>{t.overview}</h2><dl className="analysis-facts"><Fact label={t.sessions} value={snapshot.overview.sessions} language={language} /><Fact label={t.sets} value={snapshot.overview.sets} language={language} />{snapshot.overview.duration_seconds !== null && <div><dt>{t.duration}</dt><dd>{duration(snapshot.overview.duration_seconds, language)}</dd></div>}</dl>{snapshot.overview.sessions === 0 && <p className="analysis-empty">{t.noData}</p>}<MiniLineChart points={snapshot.activity.map((day) => ({ timestamp: `${day.date}T00:00:00Z`, value: day.sessions }))} unit={t.sessions.toLowerCase()} language={language} label={t.sessions} /></article>
|
||||
<article className="analysis-panel"><h2>{t.activeProgram}</h2>{snapshot.active_program === null ? <p className="analysis-empty">{t.noProgram}</p> : <><h3>{snapshot.active_program.title}</h3><p>{snapshot.active_program.completed_sessions}/{snapshot.active_program.total_sessions}</p><progress max={snapshot.active_program.total_sessions || 1} value={snapshot.active_program.completed_sessions} aria-label={t.programProgress} />{snapshot.active_program.next_session_title && <p>{t.nextSession}: {snapshot.active_program.next_session_title}</p>}<a href="/programmes">Programmes</a></>}</article>
|
||||
<article className="analysis-panel"><h2>{t.distribution}</h2>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <BodyZoneFigure zones={zoneFigure} />}<button className="analysis-link" type="button" onClick={() => chooseSection('body-zones')}>{t.distribution}</button></article>
|
||||
<article className="analysis-panel"><h2>{t.exercises}</h2>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h3>{snapshot.exercise.name}</h3><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article>
|
||||
</div>}
|
||||
{section === 'exercise' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.exercise}<select value={snapshot.exercise?.exercise_id ?? ''} onChange={(event) => chooseExercise(event.target.value)}><option value="" disabled>{t.noExercise}</option>{snapshot.exercises.map((exercise) => <option key={exercise.exercise_id} value={exercise.exercise_id}>{exercise.name}</option>)}</select></label>{snapshot.exercise === null ? <p className="analysis-empty">{t.noExercise}</p> : <><h2>{snapshot.exercise.name}</h2><ExerciseFacts points={snapshot.exercise.points} language={language} /></>}</article>}
|
||||
{section === 'body-zones' && <article className="analysis-panel analysis-wide"><h2>{t.exposures}</h2><p className="analysis-note">{t.zonesHelp}</p>{zoneFigure.length === 0 ? <p className="analysis-empty">{t.noData}</p> : <div className="analysis-zones"><BodyZoneFigure zones={zoneFigure} /><ul>{snapshot.body_zones.map((zone) => <li key={zone.zone_id}><strong>{zone.label}</strong><span>{zone.exposures} {t.exposures.toLowerCase()} · {zone.associated_sets} {t.sets.toLowerCase()}</span></li>)}</ul></div>}</article>}
|
||||
{section === 'measurements' && <article className="analysis-panel analysis-wide"><label className="analysis-picker">{t.metric}<select value={metric} onChange={(event) => chooseMetric(event.target.value as MeasurementMetric)}>{MEASUREMENT_METRICS.map((value) => <option key={value} value={value}>{metricLabels[language][value]}</option>)}</select></label><h2>{metricLabels[language][metric]}</h2>{selectedSummary === undefined || selectedSummary.count === 0 ? <p className="analysis-empty">{t.noData}</p> : <><dl className="analysis-facts"><div><dt>{t.first}</dt><dd>{selectedSummary.first === null ? '—' : `${formatNumber(selectedSummary.first, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.last}</dt><dd>{selectedSummary.last === null ? '—' : `${formatNumber(selectedSummary.last, language)} ${selectedSummary.unit}`}</dd></div><div><dt>{t.delta}</dt><dd>{selectedSummary.delta === null ? '—' : `${selectedSummary.delta > 0 ? '+' : ''}${formatNumber(selectedSummary.delta, language)} ${selectedSummary.unit}`}</dd></div></dl>{selectedSummary.count === 1 && <p className="analysis-note">{t.onePoint}</p>}<MiniLineChart points={snapshot.measurements.points} unit={selectedSummary.unit} language={language} label={metricLabels[language][metric]} /></>}</article>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { DashboardGrid } from '../dashboard/DashboardGrid'
|
||||
import type { DashboardSnapshot } from '../api/dashboard'
|
||||
import type { PreparedItemsSnapshot } from '../api/preparedItems'
|
||||
import type { AnalysisSnapshot } from '../api/analysis'
|
||||
|
||||
interface DashboardPageProps {
|
||||
dashboard: DashboardSnapshot | null
|
||||
|
|
@ -9,6 +10,7 @@ interface DashboardPageProps {
|
|||
preparedItems: PreparedItemsSnapshot | null
|
||||
preparedItemsPending: boolean
|
||||
preparedItemsFailed: boolean
|
||||
analysis: AnalysisSnapshot | null
|
||||
}
|
||||
|
||||
export function DashboardPage({
|
||||
|
|
@ -18,6 +20,7 @@ export function DashboardPage({
|
|||
preparedItems,
|
||||
preparedItemsPending,
|
||||
preparedItemsFailed,
|
||||
analysis,
|
||||
}: DashboardPageProps) {
|
||||
return (
|
||||
<section className="page" aria-labelledby="page-title">
|
||||
|
|
@ -31,6 +34,7 @@ export function DashboardPage({
|
|||
preparedItems={preparedItems}
|
||||
preparedItemsPending={preparedItemsPending}
|
||||
preparedItemsFailed={preparedItemsFailed}
|
||||
analysis={analysis}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,43 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
|
|||
.page-intro { max-width: 34rem; margin-bottom: .15rem; color: var(--subtext-0); font-size: clamp(.95rem, 1.4vw, 1.08rem); line-height: 1.65; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
.analysis-toolbar { margin-bottom: 1.5rem; display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.analysis-tabs { display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||
.analysis-tabs button { min-height: 2.6rem; padding: .55rem .8rem; color: var(--subtext-0); background: transparent; border: 1px solid var(--surface-2); border-radius: .45rem; cursor: pointer; }
|
||||
.analysis-tabs button[aria-selected="true"] { color: var(--crust); background: var(--lavender); border-color: var(--lavender); font-weight: 700; }
|
||||
.analysis-toolbar label, .analysis-language, .analysis-picker { display: grid; gap: .35rem; color: var(--subtext-0); font-size: var(--font-size-sm); }
|
||||
.analysis-toolbar select, .analysis-language select, .analysis-picker select { min-height: 2.6rem; padding: .55rem .7rem; color: var(--text); background: var(--mantle); border: 1px solid var(--surface-2); border-radius: .45rem; }
|
||||
.analysis-section-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||
.analysis-panel { min-width: 0; padding: clamp(1rem, 3vw, 1.75rem); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); overflow: hidden; }
|
||||
.analysis-panel h3 { margin-bottom: .6rem; }
|
||||
.analysis-wide { width: 100%; }
|
||||
.analysis-facts { margin: 1rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .75rem; }
|
||||
.analysis-facts div { min-width: 0; padding: .75rem; background: var(--base); border: 1px solid var(--surface-1); border-radius: .55rem; }
|
||||
.analysis-facts dt { color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||
.analysis-facts dd { margin: .3rem 0 0; overflow-wrap: anywhere; font-size: 1.1rem; font-weight: 700; }
|
||||
.analysis-empty, .analysis-note { color: var(--subtext-0); line-height: 1.55; }
|
||||
.analysis-chart { min-width: 0; margin: 1rem 0 0; }
|
||||
.analysis-chart svg { width: 100%; height: clamp(10rem, 25vw, 16rem); overflow: visible; }
|
||||
.analysis-chart polyline { stroke: var(--lavender); stroke-width: 2; }
|
||||
.analysis-chart circle { fill: var(--green); stroke: var(--base); stroke-width: .5; }
|
||||
.analysis-chart figcaption { color: var(--subtext-0); font-size: var(--font-size-xs); }
|
||||
.analysis-zones { display: grid; grid-template-columns: minmax(14rem, .8fr) minmax(16rem, 1.2fr); align-items: start; gap: 1.5rem; }
|
||||
.analysis-zones ul { margin: 0; padding: 0; display: grid; gap: .5rem; list-style: none; }
|
||||
.analysis-zones li { padding: .75rem; display: flex; justify-content: space-between; gap: 1rem; border-bottom: 1px solid var(--surface-1); }
|
||||
.analysis-zones li span { color: var(--subtext-0); text-align: right; }
|
||||
.analysis-link, .tile-context-link { width: max-content; max-width: 100%; color: var(--lavender); background: transparent; border: 0; padding: .35rem 0; text-decoration: underline; text-underline-offset: .2rem; cursor: pointer; }
|
||||
.measurement-summary dd { display: grid; gap: .2rem; }
|
||||
.measurement-summary small { color: var(--subtext-0); font-weight: 500; }
|
||||
.program-progress p { margin: .25rem 0; color: var(--subtext-0); }
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.analysis-section-grid, .analysis-zones { grid-template-columns: 1fr; }
|
||||
.analysis-page .page-heading { align-items: start; flex-direction: column; }
|
||||
.analysis-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.analysis-toolbar label { width: 100%; }
|
||||
.analysis-tabs button { flex: 1 1 8rem; }
|
||||
}
|
||||
|
||||
.sessions-toolbar { margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.detail-actions, .confirmation-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .65rem; }
|
||||
.session-tabs { display: flex; gap: .4rem; padding: .3rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue