Compare commits

..

4 commits

Author SHA1 Message Date
fy59
c31c394e4f refactor(web): improve dashboard summaries 2026-09-20 20:38:39 +02:00
fy59
109eb6fcd4 docs: document 0.1.4 analysis foundation 2026-09-20 20:35:16 +02:00
fy59
2bd68100e9 feat(web): implement analysis read models and workspace 2026-09-20 20:35:16 +02:00
fy59
451d0a36a0 chore: open 0.1.4 development 2026-09-20 20:19:54 +02:00
29 changed files with 1564 additions and 56 deletions

View file

@ -1,5 +1,19 @@
# Changelog
## Unreleased — 0.1.4
Development opened after stable v0.1.3 release.
- Implemented the loopback-only Web Analyse V1 workspace and shared bounded
Core read model. The 7/30/90-day and all-history filters, factual activity,
active-Program progress, compatible exercise metrics, explicit persisted
MAX results, primary BODY ZONE exposures, and fourteen body measurements are
calculated by the C17 backend. Dashboard summaries consume the same model
while preserving all existing tile identities and layout behavior. Deep
links, safe query fallbacks, null/empty states, French/English presentation,
keyboard focus, and responsive charts are covered without estimating 1RM,
readiness, fatigue, recovery, body fat, or performance.
## 0.1.3 — 2026-09-20
- Decoupled the Android full-generation request from the strict legacy V3

View file

@ -24,7 +24,7 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.3 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse remains a placeholder. |
| Local Web (0.1.4 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse is the current implementation cursor. |
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
@ -34,8 +34,8 @@ make the remaining placeholder routes functional.
The top-level `/programmes` route is an operational daily calendar for active
Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration is implemented for 0.1.3 development. The next
separately contracted priority is the Android UX redesign.
Web Exercises administration is implemented. The 0.1.4 development cursor is
the Web Dashboard and Analyse V1 read-model workspace.
## Releases
@ -350,7 +350,8 @@ As of 2026-09-18:
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
`/programmes` is an operational active-Program calendar, while
Sessions → Programmes remains the administration surface;
`TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
`TRAINLOG_WEB_EXERCISES_V1=PASS`; `/analyse` now provides the 0.1.4 factual
Dashboard/Analyse read-model foundation pending visual review;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;

View file

@ -43,7 +43,7 @@ val trainlogVersionCode =
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
} ?: 4
} ?: 5
android {
namespace = "com.labfytools.trainlog"
@ -55,7 +55,7 @@ android {
targetSdk = 36
versionCode = trainlogVersionCode
versionName = "0.1.3"
versionName = "0.1.4"
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -61,6 +61,14 @@ long-term history. Core owns data, persistence, business logic, calculations,
read models, command services, and versioned contracts. An interface must not
query tables directly to reconstruct its own business rules.
The Web Dashboard and Analyse workspace share `GET /api/v1/analysis`, backed by
one C17 read model. Period filtering, active-Program progress, compatible
exercise metrics, explicit MAX results, primary BODY ZONE exposures, and body
measurement first/last/delta calculations occur in Core. React validates the
bounded response and presents it; it does not load raw history or issue SQL.
Absent values remain null, and the model never estimates physiological or
performance scores.
Android local SQLite is a capture store, not a synchronization format.
## 2. Components

View file

@ -111,7 +111,7 @@ parallel implementation of its rules.
| Android SQLite | schema v25 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.3` stable, synchronized across Android and desktop; latest stable release: `v0.1.3` |
| Trainlog product version | `0.1.4` development, synchronized across Android and desktop; latest stable release: `v0.1.3` |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
@ -141,7 +141,7 @@ parallel implementation of its rules.
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
| Current operational cursor | `TRAINLOG_WEB_ANALYSIS_V1` (implemented, visual review pending) |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
@ -291,7 +291,24 @@ explicit proposal derivation and generation-backed Android delivery. The
top-level `/programmes` route is an operational daily active-Program calendar;
Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. `/exercices` is the operational desktop
catalogue administration surface; Analyse remains an explicit placeholder.
catalogue administration surface. `/analyse` is implemented with Overview,
Exercises, Distribution, and Measurements sections over one bounded Core read
model. Its global 7/30/90-day/all filter is applied in the backend; Dashboard
uses the same 30-day calculations for activity, active-Program progress,
exercise progression, measurements, and BODY ZONE links.
The Analyse API validates period, exercise identity, measurement metric and
page, rejects unknown or duplicate parameters, and bounds exercises and chart
points. Missing facts remain null. Exercise output respects persisted
tracking/load modes, explicit MAX rows are never estimated, and duration,
distance, speed, external load, and external volume appear only when their
source observations support them. BODY ZONES are labelled as recorded primary
zone exposures/associated sets rather than physiological percentages. All
fourteen stored body metrics expose period-first, period-last, and an absolute
delta only with at least two points. The route and Dashboard deep links have
safe invalid-query fallbacks, French/English labels, keyboard controls, and
responsive no-overflow layouts. Visual inspection remains pending before any
freeze marker.
Exercises uses bounded Core-owned list/detail reads, canonical name
normalization, BODY ZONES taxonomy, profiled creation/update commands and
@ -378,7 +395,7 @@ unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy.
The next private daily Android candidate remains on development
`versionName=0.1.3`; only its strictly increasing `versionCode` may be supplied
`versionName=0.1.4`; only its strictly increasing `versionCode` may be supplied
as a build-time override. No public tag or release is created by that private
candidate process.
@ -587,9 +604,8 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
`TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
a placeholder and is not a v0.1.3 priority.
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. `TRAINLOG_WEB_ANALYSIS_V1` is implemented
on the 0.1.4 branch and awaits the requested Dashboard/Analyse visual review.
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
- Session Generator V1 is hidden while V2 planning semantics are developed.

View file

@ -43,9 +43,9 @@ tile rendering exist. The Dashboard's bounded progression and BODY ZONES
visualizations and Sessions V1 are implemented. The top-level `/programmes`
route is an operational daily active-Program calendar, while
Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. Web Exercises V1 is implemented; Analyse
remains a placeholder outside this cycle unless a later explicit decision
changes that order.
detail, archive, and delete surface. Web Exercises V1 is implemented. The
0.1.4 `TRAINLOG_WEB_ANALYSIS_V1` branch now owns the factual Dashboard/Analyse
read-model foundation and stops before merge for visual inspection.
The corrective prepared-item projection, bounded phase-owned MTP outbox,
Sessions presentation correction, local date preference and durable preparation
withdrawal are implemented. The withdrawal uses the separate versioned
@ -54,12 +54,12 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
## Current cursor
The v0.1.3 development cursor proceeds from the closed stable v0.1.2 baseline
and its validated Web, Program execution, USB/Drive generation, ACK, and
causal-deletion contracts without reopening those completed gates.
The v0.1.4 cursor proceeds from stable v0.1.3 and its validated Web, Program
execution, USB/Drive generation, ACK, and causal-deletion contracts without
reopening those completed gates.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_WEB_ANALYSIS_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1,5 +1,14 @@
# Tests and validation
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
multiple-point measurement semantics, and active-Program projection. The HTTP
suite exercises the real bounded endpoint plus invalid and duplicate query
parameters. Vitest validates the typed parser, null preservation, route and
section changes, period/exercise/metric selection, invalid deep-link fallback,
critical French/English labels, and Dashboard layout regressions.
`test_sync_drive_transport` proves Drive artifact-before-manifest and
manifest-before-reference publication, rejects unsafe/AI namespaces, and keeps
a partial generation invisible. Orchestrator coverage proves that Drive mirror
@ -657,7 +666,7 @@ TUI product version, asset names, and release title must agree.
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
integer above the installed package while retaining the current development
`versionName=0.1.3`. The version-code override is build-only and does not change
`versionName=0.1.4`. The version-code override is build-only and does not change
any schema or exchange-format version. Private daily candidates must not
downgrade the logical product version to the earlier 0.1.2 stable line.

View file

@ -1,7 +1,7 @@
project(
'trainlog',
'c',
version: '0.1.3',
version: '0.1.4',
meson_version: '>=1.3.0',
default_options: [
'c_std=c17',

View file

@ -65,7 +65,7 @@ class DeploymentToolsTest(unittest.TestCase):
)
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.3")
self.assertEqual(inventory["product_version"], "0.1.4")
self.assertEqual(inventory["desktop_schema"], 28)
self.assertEqual(inventory["android_schema"], 25)
self.assertIn("session-preparations-v2", inventory["protocols"])
@ -88,7 +88,7 @@ class DeploymentToolsTest(unittest.TestCase):
text=True,
)
self.assertEqual(linked.returncode, 0, linked.stderr)
self.assertIn("trainlog 0.1.3", linked.stdout)
self.assertIn("trainlog 0.1.4", linked.stdout)
imported = subprocess.run(
[
"python3",

View file

@ -103,7 +103,7 @@ def main() -> int:
"source_commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.3",
"product_version": "0.1.4",
"desktop_schema": 28,
"android_schema": 25,
"protocols": [

View 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

View file

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

View file

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

View 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;
}

View file

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

View 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

View 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
View 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())
}

View file

@ -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]}

View file

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

View file

@ -24,9 +24,9 @@ export function muscleIntensityLevel(sessionCount: number, maximum: number): 0 |
return 3
}
interface BodyZoneFigureProps { zones: WorkedZone[]; views?: 'front' | 'both' }
interface BodyZoneFigureProps { zones: WorkedZone[]; views?: 'front' | 'both'; language?: 'fr' | 'en' }
export function BodyZoneFigure({ zones, views = 'both' }: BodyZoneFigureProps) {
export function BodyZoneFigure({ zones, views = 'both', language = 'fr' }: BodyZoneFigureProps) {
const [focused, setFocused] = useState<WorkedZone | null>(null)
const byId = new Map(zones.map((zone) => [zone.zone_id, zone]))
const maximum = Math.max(1, ...zones.map((zone) => zone.session_count))
@ -37,25 +37,27 @@ export function BodyZoneFigure({ zones, views = 'both' }: BodyZoneFigureProps) {
className: `body-region intensity-${muscleIntensityLevel(zone.session_count, maximum)}`,
tabIndex: 0,
role: 'button',
'aria-label': `${zone.label} : ${count(zone.session_count, 'séance')}, ${count(zone.occurrence_count, 'occurrence')}, ${count(zone.set_count, 'série')}.`,
'aria-label': language === 'fr'
? `${zone.label} : ${count(zone.session_count, 'séance')}, ${count(zone.occurrence_count, 'occurrence')}, ${count(zone.set_count, 'série')}.`
: `${zone.label}: ${zone.session_count} ${zone.session_count === 1 ? 'session' : 'sessions'}, ${zone.occurrence_count} ${zone.occurrence_count === 1 ? 'exposure' : 'exposures'}, ${zone.set_count} ${zone.set_count === 1 ? 'associated set' : 'associated sets'}.`,
onFocus: () => setFocused(zone), onBlur: () => setFocused(null),
onMouseEnter: () => setFocused(zone), onMouseLeave: () => setFocused(null),
}
}
return <div className="body-zone-figure">
<div className="body-views">
<BodyView title="Avant" side="front" zoneProps={zoneProps} />
{views === 'both' && <BodyView title="Arrière" side="back" zoneProps={zoneProps} />}
<BodyView title={language === 'fr' ? 'Avant' : 'Front'} side="front" zoneProps={zoneProps} language={language} />
{views === 'both' && <BodyView title={language === 'fr' ? 'Arrière' : 'Back'} side="back" zoneProps={zoneProps} language={language} />}
</div>
<div className="muscle-legend" aria-label="Échelle visuelle des séances sur 30 jours"><span className="intensity-0" />0<span className="intensity-1" />Faible<span className="intensity-2" />Intermédiaire<span className="intensity-3" />Maximum observé</div>
{focused && <div className="body-tooltip" role="status"><strong>{focused.label}</strong><span>{count(focused.session_count, 'séance')} · {count(focused.occurrence_count, 'occurrence')} · {count(focused.set_count, 'série')}</span></div>}
<div className="muscle-legend" aria-label={language === 'fr' ? 'Échelle visuelle des séances sur 30 jours' : 'Visual scale of sessions over 30 days'}><span className="intensity-0" />0<span className="intensity-1" />{language === 'fr' ? 'Faible' : 'Low'}<span className="intensity-2" />{language === 'fr' ? 'Intermédiaire' : 'Medium'}<span className="intensity-3" />{language === 'fr' ? 'Maximum observé' : 'Observed maximum'}</div>
{focused && <div className="body-tooltip" role="status"><strong>{focused.label}</strong><span>{language === 'fr' ? `${count(focused.session_count, 'séance')} · ${count(focused.occurrence_count, 'occurrence')} · ${count(focused.set_count, 'série')}` : `${focused.session_count} sessions · ${focused.occurrence_count} exposures · ${focused.set_count} associated sets`}</span></div>}
</div>
}
type RegionProps = (zoneId: MappedBodyZoneId) => Record<string, unknown>
function BodyView({ title, side, zoneProps }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps }) {
return <figure><figcaption>{title}</figcaption><svg viewBox="0 0 120 260" role="group" aria-label={`Silhouette, vue ${title.toLowerCase()}`}>
function BodyView({ title, side, zoneProps, language }: { title: string; side: 'front' | 'back'; zoneProps: RegionProps; language: 'fr' | 'en' }) {
return <figure><figcaption>{title}</figcaption><svg viewBox="0 0 120 260" role="group" aria-label={language === 'fr' ? `Silhouette, vue ${title.toLowerCase()}` : `Silhouette, ${title.toLowerCase()} view`}>
<circle className="body-outline" cx="60" cy="23" r="16" />
<path className="body-outline" d="M42 43 Q60 36 78 43 L88 116 75 150 70 244H52L45 150 32 116Z" />
{side === 'front' ? <>

View file

@ -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 () => {

View file

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

View file

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

View 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()
})
})

View file

@ -0,0 +1,133 @@
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>>
const zoneLabelsEn: Readonly<Record<string, string>> = {
full_body: 'Full body', upper_body: 'Upper body', chest: 'Chest', back: 'Back',
shoulders: 'Shoulders', arms: 'Arms', core: 'Core', lower_body: 'Lower body',
glutes: 'Glutes', thighs: 'Thighs', calves: 'Calves',
}
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: language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : 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} language={language} />}<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} language={language} /><ul>{snapshot.body_zones.map((zone) => <li key={zone.zone_id}><strong>{language === 'en' ? zoneLabelsEn[zone.zone_id] ?? zone.zone_id : 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>
}

View file

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

View file

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